blob: 48cee2798850ecd6c883e9d934d4405f79520761 [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.
from typing import Callable, Optional, Set
import pyarrow
from pyarrow import RecordBatch
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
from pypaimon.table.row.blob import Blob, BlobViewStruct
class BlobInlineConvertReader(RecordBatchReader):
"""Resolves BlobView and BlobDescriptor fields in record batches.
Processing is split into two clear stages:
Stage 1 (BlobView resolution): If view fields exist, use a lightweight
prescan reader (only projecting view columns) to collect
BlobViewStructs, bulk-preload their descriptors, then read
full data from the main reader and replace view field values
with descriptor bytes or real blob data according to the
blob-as-descriptor option.
Stage 2 (BlobDescriptor resolution): Controlled by blob-as-descriptor option.
If false, resolve BlobDescriptor bytes from descriptor fields
into real blob data bytes. BlobView fields are already resolved
in Stage 1 with the upstream table FileIO.
If true, return as-is.
"""
def __init__(self, inner: RecordBatchReader, table,
prescan_reader_factory: Optional[Callable[[Set[str]], RecordBatchReader]] = None,
blob_parallelism: int = 1):
"""
Args:
inner: The main data reader (reads all columns).
table: The table instance.
prescan_reader_factory: Optional factory that creates a lightweight
reader projecting only the specified field names. Used for
prescan to collect BlobViewStructs without reading all columns.
Signature: (field_names: Set[str]) -> RecordBatchReader
blob_parallelism: number of threads for concurrent blob reads.
"""
self._inner = inner
self._table = table
self._prescan_reader_factory = prescan_reader_factory
self._blob_parallelism = blob_parallelism
self._adopt_metadata(inner)
# Preserve original BlobViewStruct bytes when resolve disabled: skip both
# view resolution (Stage 1) and descriptor-to-data resolution (Stage 2).
resolve_enabled = CoreOptions.blob_view_resolve_enabled(
table.options) and self._table.catalog_environment.catalog_loader is not None
self._view_fields = CoreOptions.blob_view_fields(table.options) if resolve_enabled else set()
self._descriptor_fields = CoreOptions.blob_descriptor_fields(table.options)
self._blob_as_descriptor = CoreOptions.blob_as_descriptor(table.options)
self._prescan_done = False
self._blob_view_lookup = None
def read_arrow_batch(self) -> Optional[RecordBatch]:
# Align with Java: only enter blob view resolution when catalog_loader is available
# If catalog_loader is None, skip both Stage 1 (view resolution) and Stage 2 (descriptor resolution)
if self._view_fields and not self._prescan_done:
self._prescan_view_structs()
batch = self._inner.read_arrow_batch()
if batch is None:
return None
# Resolve view fields using the preloaded lookup
view_file_ios = {}
if self._view_fields and self._blob_view_lookup is not None:
batch, view_file_ios = self._resolve_view_fields(batch, self._blob_view_lookup)
# Resolve BlobDescriptor -> real bytes (if blob-as-descriptor=false)
return self._resolve_descriptor_fields(batch, view_file_ios)
# ------------------------------------------------------------------
# Stage 1: BlobView prescan (lightweight, only reads view columns)
# ------------------------------------------------------------------
def _prescan_view_structs(self):
"""Use a lightweight prescan reader (projecting only view columns) to
collect all BlobViewStructs and bulk-preload their descriptors."""
from pypaimon.table.row.blob import BlobViewStruct
from pypaimon.utils.blob_view_lookup import BlobViewLookup
all_view_structs = []
prescan_reader = self._prescan_reader_factory(self._view_fields)
try:
while True:
batch = prescan_reader.read_arrow_batch()
if batch is None:
break
for field_name in self._view_fields:
if field_name not in batch.schema.names:
continue
for value in batch.column(field_name).to_pylist():
value = self._normalize_blob_to_bytes(value)
if value is None:
continue
if isinstance(value, bytes) and BlobViewStruct.is_blob_view_struct(value):
all_view_structs.append(BlobViewStruct.deserialize(value))
else:
raise ValueError(
f"Expected BlobViewStruct bytes in view field '{field_name}', "
f"but got non-BlobViewStruct bytes."
)
finally:
prescan_reader.close()
# Bulk-preload BlobViewStruct -> BlobDescriptor mapping
if all_view_structs:
self._blob_view_lookup = BlobViewLookup(self._table)
self._blob_view_lookup.preload(all_view_structs)
self._prescan_done = True
def _resolve_view_fields(self, batch, blob_view_lookup):
"""Replace BlobViewStruct bytes in view fields with descriptor bytes."""
view_file_ios = {}
for field_name in self._view_fields:
if field_name not in batch.schema.names:
continue
values = [self._normalize_blob_to_bytes(v) for v in batch.column(field_name).to_pylist()]
converted_values = []
field_file_ios = []
for value in values:
if value is None or not (
isinstance(value, bytes) and BlobViewStruct.is_blob_view_struct(value)):
converted_values.append(value)
field_file_ios.append(None)
continue
view_struct = BlobViewStruct.deserialize(value)
if blob_view_lookup.resolve_to_null(view_struct):
converted_values.append(None)
field_file_ios.append(None)
else:
descriptor = blob_view_lookup.resolve_descriptor(view_struct)
converted_values.append(descriptor.serialize())
file_io = blob_view_lookup.resolve_file_io(view_struct)
field_file_ios.append(file_io)
column_idx = batch.schema.names.index(field_name)
batch = batch.set_column(
column_idx,
pyarrow.field(field_name, pyarrow.large_binary(), nullable=True),
pyarrow.array(converted_values, type=pyarrow.large_binary()),
)
view_file_ios[field_name] = field_file_ios
return batch, view_file_ios
# ------------------------------------------------------------------
# Stage 2: BlobData resolution (unified exit)
# ------------------------------------------------------------------
def _resolve_descriptor_fields(self, batch, view_file_ios=None):
if self._blob_as_descriptor:
return batch
for field_name in self._descriptor_fields:
if field_name not in batch.schema.names:
continue
values = [self._normalize_blob_to_bytes(v) for v in batch.column(field_name).to_pylist()]
blobs = [Blob.from_bytes(v, self._table.file_io) for v in values]
if self._blob_parallelism > 1:
converted_values = self._table.file_io.read_blobs_concurrent(
blobs, self._blob_parallelism)
else:
converted_values = [b.to_data() if b else None for b in blobs]
column_idx = batch.schema.names.index(field_name)
batch = batch.set_column(
column_idx,
pyarrow.field(field_name, pyarrow.large_binary(), nullable=True),
pyarrow.array(converted_values, type=pyarrow.large_binary()),
)
view_file_ios = view_file_ios or {}
for field_name in self._view_fields:
field_file_ios = view_file_ios.get(field_name)
if field_name not in batch.schema.names or field_file_ios is None:
continue
values = [self._normalize_blob_to_bytes(v) for v in batch.column(field_name).to_pylist()]
blobs_by_file_io = {}
converted_values = []
for idx, value in enumerate(values):
file_io = field_file_ios[idx] or self._table.file_io
blob = Blob.from_bytes(value, file_io)
if self._blob_parallelism > 1:
converted_values.append(None)
if blob is not None:
blobs_by_file_io.setdefault(file_io, []).append((idx, blob))
else:
converted_values.append(blob.to_data() if blob else None)
for file_io, indexed_blobs in blobs_by_file_io.items():
blobs = [item[1] for item in indexed_blobs]
results = file_io.read_blobs_concurrent(blobs, self._blob_parallelism)
for (idx, _), data in zip(indexed_blobs, results):
converted_values[idx] = data
column_idx = batch.schema.names.index(field_name)
batch = batch.set_column(
column_idx,
pyarrow.field(field_name, pyarrow.large_binary(), nullable=True),
pyarrow.array(converted_values, type=pyarrow.large_binary()),
)
return batch
# ------------------------------------------------------------------
# Utilities
# ------------------------------------------------------------------
@staticmethod
def _normalize_blob_to_bytes(value):
if value is None:
return None
if hasattr(value, 'as_py'):
value = value.as_py()
if isinstance(value, str):
value = value.encode('utf-8')
if isinstance(value, bytearray):
value = bytes(value)
return value
def close(self):
self._inner.close()