blob: 65ea655e5a4d5d3f30d8c50d798f9b7cc24e8e96 [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
import pyarrow as pa
from typing import Optional, Tuple, Dict
from pypaimon.execution.python.write.format_file_writer import (
write_blob_file
)
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.data.timestamp import Timestamp
from pypaimon.table.row.blob import (
BlobConsumer,
video_payload_descriptor,
)
from pypaimon.execution.python.write.writer.append_only_data_writer import AppendOnlyDataWriter
from pypaimon.execution.python.write.writer.blob_file_writer import BlobFileWriter
from pypaimon.execution.python.write.writer.video_group import VideoGroupRollingPolicy
logger = logging.getLogger(__name__)
class BlobWriter(AppendOnlyDataWriter):
def __init__(self, table, partition: Tuple, bucket: int, max_seq_number: int, blob_column: str,
options: Dict[str, str] = None, blob_consumer: Optional[BlobConsumer] = None,
video: bool = False, uri_reader_factory=None):
super().__init__(table, partition, bucket, max_seq_number,
options, write_cols=[blob_column])
self.video = video
if video and blob_consumer is not None:
raise ValueError(
f"BlobConsumer is not supported for video frame field '{blob_column}'."
)
self.file_format = (
CoreOptions.FILE_FORMAT_VIDEO
if video
else CoreOptions.FILE_FORMAT_BLOB
)
# Store blob column name for use in metadata creation
self.blob_column = blob_column
options = self.table.options
self.blob_target_file_size = CoreOptions.blob_target_file_size(options)
# Use the effective options (constructor-provided), consistent with the other accessors.
self.blob_copy_buffer_size = self.options.blob_copy_buffer_size()
self._blob_consumer = blob_consumer
self._uri_reader_factory = uri_reader_factory
self.current_writer: Optional[BlobFileWriter] = None
self.current_file_path: Optional[str] = None
self.record_count = 0
self.file_uuid = str(uuid.uuid4())
self.file_count = 0
self._video_group_policy = VideoGroupRollingPolicy() if video else None
logger.info(f"Initialized BlobWriter with blob file format, blob_target_file_size={self.blob_target_file_size}")
def _check_and_roll_if_needed(self):
# Rolling here is driven by the size of the external blobs, which the
# buffered descriptors say nothing about, so there is no cheap count to
# check first: every write drains the buffer.
pending = self._buffer.take()
if pending is None:
return
# Always write blob rows one-by-one so rolling uses actual blob bytes size rather than
# in-memory serialized descriptor size.
for i in range(pending.num_rows):
row_data = pending.slice(i, 1)
next_group = video_payload_descriptor(row_data.column(0)[0])
self._roll_before_video_group(next_group)
self._write_row_to_file(row_data)
self.record_count += 1
if self._video_group_policy is not None:
self._video_group_policy.record(next_group)
if self.rolling_file():
if (
self._video_group_policy is None
or not self._video_group_policy.defer_roll()
):
self.close_current_writer()
def _write_row_to_file(self, row_data: pa.Table):
"""Write a single row to the current blob file. Opens a new file if needed."""
if row_data.num_rows == 0:
return
if self.current_writer is None:
self.open_current_writer()
self.current_writer.write_row(row_data)
# This ensures each row has a unique sequence number for data versioning and consistency
self.sequence_generator.next()
def write_blob(self, value, arrow_type=pa.large_binary()):
next_group = video_payload_descriptor(value)
self._roll_before_video_group(next_group)
if self.current_writer is None:
self.open_current_writer()
self.current_writer.write_blob(self.blob_column, arrow_type, value)
self.sequence_generator.next()
self.record_count += 1
if self._video_group_policy is not None:
self._video_group_policy.record(next_group)
if self.rolling_file():
if (
self._video_group_policy is None
or not self._video_group_policy.defer_roll()
):
self.close_current_writer()
def open_current_writer(self):
file_name = (f"{CoreOptions.data_file_prefix(self.options)}"
f"{self.file_uuid}-{self.file_count}.{self.file_format}")
self.file_count += 1 # Increment counter for next file
file_path = self._generate_file_path(file_name)
self.current_file_path = file_path
self.current_writer = BlobFileWriter(
self.file_io,
file_path,
blob_consumer=self._blob_consumer,
copy_buffer_size=self.blob_copy_buffer_size,
video=self.video,
uri_reader_factory=self._uri_reader_factory,
)
def rolling_file(self) -> bool:
if self.current_writer is None:
return False
return (
self.current_writer.row_count >= self.target_file_row_num
or self.current_writer.reach_target_size(self.blob_target_file_size)
)
def should_roll_before_group(self, row_count: int) -> bool:
return (
self._video_group_policy is not None
and self.current_writer is not None
and (
self._video_group_policy.pending_roll
or self.current_writer.row_count + row_count
> self.target_file_row_num
)
)
def close_current_writer(self):
"""Close current writer and create metadata."""
if self.current_writer is None:
return
file_size = self.current_writer.close()
file_name = self.current_file_path.split('/')[-1]
row_count = self.current_writer.row_count
# Determine if this is an external path
is_external_path = self.external_path_provider is not None
external_path_str = self.current_file_path if is_external_path else None
self._add_file_metadata(file_name, self.current_file_path, row_count, file_size, external_path_str)
self.current_writer = None
self.current_file_path = None
if self._video_group_policy is not None:
self._video_group_policy.reset()
def _roll_before_video_group(self, next_group):
if (
self._video_group_policy is not None
and self.current_writer is not None
and self._video_group_policy.should_roll_before(next_group)
):
self.close_current_writer()
def _write_data_to_file(self, data):
"""
Keep a fallback path for direct blob table writes while preserving the writer uuid+counter
naming behavior.
"""
if data.num_rows == 0:
return
# This ensures each row gets a unique sequence number, matching the behavior expected
for _ in range(data.num_rows):
self.sequence_generator.next()
file_name = f"data-{self.file_uuid}-{self.file_count}.{self.file_format}"
self.file_count += 1
file_path = self._generate_file_path(file_name)
write_blob_file(self.file_io, file_path, data)
file_size = self.file_io.get_file_size(file_path)
is_external_path = self.external_path_provider is not None
external_path_str = file_path if is_external_path else None
# Reuse _add_file_metadata for consistency (blob table is append-only, no primary keys)
self._add_file_metadata(file_name, file_path, data, file_size, external_path_str)
def _add_file_metadata(self, file_name: str, file_path: str, data_or_row_count, file_size: int,
external_path: Optional[str] = None):
"""Add file metadata to committed_files."""
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.simple_stats import SimpleStats
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.schema.data_types import PyarrowFieldParser
# Handle both Table and row_count
if isinstance(data_or_row_count, pa.Table):
data = data_or_row_count
row_count = data.num_rows
data_fields = PyarrowFieldParser.to_paimon_schema(data.schema)
# Compute statistics across all batches, not just the first one
# This ensures correct min/max/null_counts when data has multiple batches
column_stats = {
field.name: self._get_column_stats(data, field.name)
for field in data_fields
}
min_value_stats = [column_stats[field.name]['min_values'] for field in data_fields]
max_value_stats = [column_stats[field.name]['max_values'] for field in data_fields]
value_null_counts = [column_stats[field.name]['null_counts'] for field in data_fields]
else:
# row_count only (from BlobFileWriter)
row_count = data_or_row_count
data_fields = [self.table.field_dict[self.blob_column]]
min_value_stats = [None]
max_value_stats = [None]
value_null_counts = [0]
min_seq = self.sequence_generator.current - row_count
max_seq = self.sequence_generator.current - 1
self.sequence_generator.start = self.sequence_generator.current
self.committed_files.append(DataFileMeta.create(
file_name=file_name,
file_size=file_size,
row_count=row_count,
min_key=GenericRow([], []),
max_key=GenericRow([], []),
key_stats=SimpleStats(GenericRow([], []), GenericRow([], []), []),
value_stats=SimpleStats(
GenericRow(min_value_stats, data_fields),
GenericRow(max_value_stats, data_fields),
value_null_counts),
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().to_millis_timestamp(),
delete_row_count=0,
file_source=0, # FileSource.APPEND = 0
value_stats_cols=None,
external_path=external_path,
first_row_id=None,
write_cols=self.write_cols,
file_path=file_path,
))
def prepare_commit(self):
"""Prepare commit, preserving blob files in write/rolling order."""
# Close current file if open.
if self.current_writer is not None:
self.close_current_writer()
# Call parent to flush anything left in the buffer.
return super().prepare_commit()
def close(self):
"""Close current writer if open."""
# Close current file if open.
if self.current_writer is not None:
self.close_current_writer()
# Call parent to flush anything left in the buffer.
super().close()
def delete_file_upon_abort(self) -> bool:
return self._blob_consumer is None
def abort(self):
if self.current_writer is not None:
try:
self.current_writer.abort()
except Exception as e:
logger.warning(f"Error aborting blob writer: {e}", exc_info=e)
self.current_writer = None
self.current_file_path = None
if self._video_group_policy is not None:
self._video_group_policy.reset()
if not self.delete_file_upon_abort():
self._buffer.reset()
self.committed_files.clear()
else:
super().abort()
@staticmethod
def _get_column_stats(data_or_batch, column_name: str):
"""
Compute column statistics for a column in a Table or RecordBatch.
"""
# Handle both Table and RecordBatch
if isinstance(data_or_batch, pa.Table):
column_array = data_or_batch.column(column_name)
else:
column_array = data_or_batch.column(column_name)
# For blob data, don't generate min/max values
return {
"min_values": None,
"max_values": None,
"null_counts": column_array.null_count,
}