This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 2bd3a6533a [python] Route descriptor-backed BLOB reads through the
table FileIO. (#9608)
2bd3a6533a is described below
commit 2bd3a6533aed100ee29e88f23fd85d5be70275a8
Author: Wenchao Wu <[email protected]>
AuthorDate: Mon Sep 21 12:02:24 2026 +0800
[python] Route descriptor-backed BLOB reads through the table FileIO.
(#9608)
---
paimon-python/pypaimon/common/file_io.py | 57 +-
paimon-python/pypaimon/common/uri_reader.py | 119 +-
.../pypaimon/read/reader/auth_masking_reader.py | 40 +-
.../read/reader/blob_descriptor_convert_reader.py | 95 +-
.../pypaimon/read/reader/blob_view_read_support.py | 68 ++
.../pypaimon/read/reader/concat_batch_reader.py | 6 +-
.../read/reader/deferred_blob_resolve_reader.py | 5 +
.../pypaimon/read/reader/field_indices.py | 22 +
.../read/reader/filter_record_batch_reader.py | 3 +
.../pypaimon/read/reader/filter_record_reader.py | 2 +
.../read/reader/iface/record_batch_reader.py | 17 +-
.../pypaimon/read/reader/iface/record_reader.py | 15 +
.../pypaimon/read/reader/limited_record_reader.py | 3 +
.../read/reader/nested_leaf_batch_reader.py | 8 +-
.../read/reader/outer_projection_record_reader.py | 19 +-
.../read/reader/row_range_filter_record_reader.py | 1 +
paimon-python/pypaimon/read/split_read.py | 176 ++-
paimon-python/pypaimon/read/table_read.py | 9 +
paimon-python/pypaimon/table/row/offset_row.py | 56 +-
paimon-python/pypaimon/tests/blob_table_test.py | 203 ++++
paimon-python/pypaimon/tests/blob_test.py | 1192 ++++++++++++++++++++
.../pypaimon/tests/resolving_file_io_test.py | 34 +
.../pypaimon/tests/uri_reader_factory_test.py | 91 ++
paimon-python/pypaimon/tests/vector_table_test.py | 7 +
paimon-python/pypaimon/utils/blob_view_lookup.py | 41 +-
25 files changed, 2170 insertions(+), 119 deletions(-)
diff --git a/paimon-python/pypaimon/common/file_io.py
b/paimon-python/pypaimon/common/file_io.py
index 3baf4813a5..23b2e51248 100644
--- a/paimon-python/pypaimon/common/file_io.py
+++ b/paimon-python/pypaimon/common/file_io.py
@@ -425,26 +425,51 @@ class FileIO(ABC):
def read_blobs_concurrent(self, blobs, parallelism):
"""Read a list of Blobs concurrently, coalescing same-file ranged
reads.
- ``BlobRef`` values expose a file range and are coalesced; in-memory
- ``BlobData`` values are returned directly.
+ Exact ``BlobRef`` values (not subclasses) with a file-backed UriReader
+ are coalesced through that FileIO so table-scoped credentials are
+ preserved. Subclasses may override ``new_input_stream()`` and must not
+ be bypassed. Other readers (for example HTTP) read through the Blob.
"""
- from pypaimon.table.row.blob import BlobRef
+ from concurrent.futures import ThreadPoolExecutor
+
+ from pypaimon.common.uri_reader import FileUriReader
+ from pypaimon.table.row.blob import BlobData, BlobRef
+
results: List[Optional[bytes]] = [None] * len(blobs)
- ranges: List[Optional[tuple]] = [None] * len(blobs)
- inmem = []
- for i, b in enumerate(blobs):
- if b is None:
+ file_groups = {}
+ other_blobs = []
+ for index, blob in enumerate(blobs):
+ if blob is None:
continue
- if isinstance(b, BlobRef):
- d = b.to_descriptor()
- ranges[i] = (d.uri, d.offset, d.length)
+ if isinstance(blob, BlobData):
+ results[index] = blob.to_data()
+ elif type(blob) is BlobRef and isinstance(
+ blob.uri_reader, FileUriReader):
+ descriptor = blob.to_descriptor()
+ source_file_io = blob.uri_reader.file_io
+ group = file_groups.setdefault(
+ id(source_file_io), (source_file_io, []))[1]
+ group.append((index, (
+ descriptor.uri, descriptor.offset, descriptor.length)))
else:
- inmem.append((i, b))
- for i, v in enumerate(self.read_ranges_coalesced(ranges, parallelism)):
- if v is not None:
- results[i] = v
- for idx, b in inmem:
- results[idx] = b.to_data()
+ other_blobs.append((index, blob))
+
+ for source_file_io, indexed_ranges in file_groups.values():
+ ranges = [value for _, value in indexed_ranges]
+ values = source_file_io.read_ranges_coalesced(ranges, parallelism)
+ for (index, _), value in zip(indexed_ranges, values):
+ results[index] = value
+
+ if other_blobs:
+ workers = max(1, min(parallelism, len(other_blobs)))
+
+ def _read_blob(indexed_blob):
+ return indexed_blob[1].to_data()
+
+ with ThreadPoolExecutor(workers) as pool:
+ values = pool.map(_read_blob, other_blobs)
+ for (index, _), value in zip(other_blobs, values):
+ results[index] = value
return results
def read_file_utf8(self, path: str) -> str:
diff --git a/paimon-python/pypaimon/common/uri_reader.py
b/paimon-python/pypaimon/common/uri_reader.py
index a417a0c9ae..5548329c29 100644
--- a/paimon-python/pypaimon/common/uri_reader.py
+++ b/paimon-python/pypaimon/common/uri_reader.py
@@ -16,6 +16,7 @@
# under the License.
import io
+import weakref
from abc import ABC, abstractmethod
from typing import Any, Optional, Union
from urllib.parse import urlparse, ParseResult
@@ -57,6 +58,10 @@ class FileUriReader(UriReader):
def __init__(self, file_io: Any):
self._file_io = file_io
+ @property
+ def file_io(self) -> Any:
+ return self._file_io
+
def new_input_stream(self, uri: str):
try:
return self._file_io.new_input_stream(uri)
@@ -109,8 +114,33 @@ class UriReaderFactory:
def __init__(self, catalog_options: Union[Options, dict]) -> None:
self.catalog_options = catalog_options if isinstance(catalog_options,
Options) else Options(catalog_options)
- self._readers =
LRUCache(CatalogOptions.BLOB_FILE_IO_DEFAULT_CACHE_SIZE)
self._readers_lock = rwlock.RWLockFair()
+ # FileIOs created by this factory. Do not close them on LRU eviction:
+ # live BlobRefs may still hold the corresponding UriReader.
+ self._owned_file_ios = []
+ self._closing = False
+ self._readers = self._new_reader_cache()
+
+ _FROM_FILE_IO_FACTORIES = weakref.WeakKeyDictionary()
+
+ @staticmethod
+ def from_file_io(file_io: Any) -> 'UriReaderFactory':
+ """Reuse a token-aware FileIO for non-HTTP URIs (Java fromFileIO)."""
+ try:
+ cached = UriReaderFactory._FROM_FILE_IO_FACTORIES.get(file_io)
+ except TypeError:
+ return _ProvidedFileIOUriReaderFactory(file_io)
+ if cached is not None:
+ return cached
+ factory = _ProvidedFileIOUriReaderFactory(file_io)
+ try:
+ UriReaderFactory._FROM_FILE_IO_FACTORIES[file_io] = factory
+ except TypeError:
+ pass
+ return factory
+
+ def _new_reader_cache(self) -> LRUCache:
+ return LRUCache(CatalogOptions.BLOB_FILE_IO_DEFAULT_CACHE_SIZE)
def create(self, input_uri: str) -> UriReader:
try:
@@ -148,12 +178,38 @@ class UriReaderFactory:
from pypaimon.common.file_io import FileIO
uri_string = parsed_uri.geturl()
file_io = FileIO.get(uri_string, self.catalog_options)
+ self._owned_file_ios.append(file_io)
return UriReader.from_file(file_io)
except Exception as e:
raise RuntimeError(f"Failed to create reader for URI
{parsed_uri.geturl()}") from e
def clear_cache(self) -> None:
- self._readers.clear()
+ if self._closing:
+ return
+ self._closing = True
+ wlock = self._readers_lock.gen_wlock()
+ wlock.acquire()
+ try:
+ file_ios = list(self._owned_file_ios)
+ self._owned_file_ios = []
+ self._readers = self._new_reader_cache()
+ finally:
+ wlock.release()
+ first_error = None
+ try:
+ for file_io in file_ios:
+ try:
+ file_io.close()
+ except Exception as error:
+ if first_error is None:
+ first_error = error
+ finally:
+ self._closing = False
+ if first_error is not None:
+ raise first_error
+
+ def close(self) -> None:
+ self.clear_cache()
def get_cache_size(self) -> int:
return len(self._readers)
@@ -161,8 +217,67 @@ class UriReaderFactory:
def __getstate__(self):
state = self.__dict__.copy()
del state['_readers_lock']
+ del state['_readers']
+ del state['_owned_file_ios']
return state
def __setstate__(self, state):
self.__dict__.update(state)
self._readers_lock = rwlock.RWLockFair()
+ self._owned_file_ios = []
+ self._closing = False
+ self._readers = self._new_reader_cache()
+
+
+class _ProvidedFileIOUriReaderFactory(UriReaderFactory):
+ """Resolves HTTP(S) via HttpUriReader and every other URI through
file_io."""
+
+ def __init__(self, file_io: Any) -> None:
+ super().__init__({})
+ self._bind_provided_file_io(file_io)
+
+ def _bind_provided_file_io(self, file_io: Any) -> None:
+ try:
+ self._provided_file_io = weakref.ref(file_io)
+ except TypeError:
+ # Not weakref-able, and therefore also not a WeakKeyDictionary
+ # key — from_file_io does not cache these objects.
+ self._provided_file_io = lambda: file_io
+
+ def _resolved_file_io(self):
+ file_io = self._provided_file_io()
+ if file_io is None:
+ raise RuntimeError(
+ "FileIO used by UriReaderFactory.from_file_io was garbage
collected")
+ return file_io
+
+ def __getstate__(self):
+ state = super().__getstate__()
+ # weakref.ref (and the TypeError fallback lambda) cannot be pickled.
+ # Resolve to a strong FileIO for the wire; __setstate__ re-wraps.
+ state['_provided_file_io'] = self._resolved_file_io()
+ return state
+
+ def __setstate__(self, state):
+ file_io = state.pop('_provided_file_io')
+ super().__setstate__(state)
+ self._bind_provided_file_io(file_io)
+
+ def create(self, input_uri: str) -> UriReader:
+ try:
+ parsed_uri = urlparse(input_uri)
+ except Exception as e:
+ raise ValueError("Invalid URI: %s" % input_uri) from e
+ scheme = (parsed_uri.scheme or '').lower()
+ if scheme in ('http', 'https'):
+ return super().create(input_uri)
+ # Do not LRU-cache FileUriReader: it holds FileIO strongly and would
+ # pin the WeakKeyDictionary key. Every non-HTTP URI already wraps the
+ # same provided FileIO, so the cache buys nothing here.
+ return UriReader.from_file(self._resolved_file_io())
+
+ def _new_reader(self, key: UriKey, parsed_uri: ParseResult) -> UriReader:
+ scheme = (key.scheme or '').lower()
+ if scheme in ('http', 'https'):
+ return UriReader.from_http()
+ return UriReader.from_file(self._resolved_file_io())
diff --git a/paimon-python/pypaimon/read/reader/auth_masking_reader.py
b/paimon-python/pypaimon/read/reader/auth_masking_reader.py
index bf425581ab..4ec25369fd 100644
--- a/paimon-python/pypaimon/read/reader/auth_masking_reader.py
+++ b/paimon-python/pypaimon/read/reader/auth_masking_reader.py
@@ -40,7 +40,10 @@ class RecordReaderToBatchAdapter(RecordBatchReader):
self._exhausted = False
self._pending_iterator = None
self._include_row_kind = include_row_kind
+ self.file_io = getattr(inner, 'file_io', None)
self.blob_field_indices = getattr(inner, 'blob_field_indices', None)
+ self.descriptor_field_indices = getattr(inner,
'descriptor_field_indices', None)
+ self.blob_view_lookup = getattr(inner, 'blob_view_lookup', None)
self.vector_field_indices = getattr(inner, 'vector_field_indices',
None)
def read_arrow_batch(self) -> Optional[pa.RecordBatch]:
@@ -66,6 +69,7 @@ class RecordReaderToBatchAdapter(RecordBatchReader):
self._exhausted = True
break
self._pending_iterator = row_iterator
+ self._refresh_blob_view_lookup(self._inner)
if not row_tuples:
return None
@@ -95,12 +99,25 @@ class BatchToRecordReaderAdapter(RecordReader):
def __init__(self, inner: RecordBatchReader):
self._inner = inner
+ self.file_io = getattr(inner, 'file_io', None)
+ self.blob_field_indices = getattr(inner, 'blob_field_indices', None)
+ self.descriptor_field_indices = getattr(inner,
'descriptor_field_indices', None)
+ self.blob_view_lookup = getattr(inner, 'blob_view_lookup', None)
+ self.vector_field_indices = getattr(inner, 'vector_field_indices',
None)
def read_batch(self):
batch = self._inner.read_arrow_batch()
if batch is None:
return None
- return _ArrowBatchIterator(batch)
+ self._refresh_blob_view_lookup(self._inner)
+ return _ArrowBatchIterator(
+ batch,
+ file_io=self.file_io,
+ blob_field_indices=self.blob_field_indices,
+ descriptor_field_indices=self.descriptor_field_indices,
+ blob_view_lookup=self.blob_view_lookup,
+ vector_field_indices=self.vector_field_indices,
+ )
def close(self):
self._inner.close()
@@ -108,7 +125,10 @@ class BatchToRecordReaderAdapter(RecordReader):
class _ArrowBatchIterator(RecordIterator):
- def __init__(self, batch: pa.RecordBatch):
+ def __init__(self, batch: pa.RecordBatch,
+ file_io=None, blob_field_indices=None,
+ descriptor_field_indices=None, blob_view_lookup=None,
+ vector_field_indices=None):
self._batch = batch
self._idx = 0
self._has_rk = "_row_kind" in batch.schema.names
@@ -118,6 +138,11 @@ class _ArrowBatchIterator(RecordIterator):
else:
self._rk_idx = -1
self._data_cols = list(range(batch.num_columns))
+ self._file_io = file_io
+ self._blob_field_indices = blob_field_indices
+ self._descriptor_field_indices = descriptor_field_indices
+ self._blob_view_lookup = blob_view_lookup
+ self._vector_field_indices = vector_field_indices
def next(self):
if self._idx >= self._batch.num_rows:
@@ -126,7 +151,13 @@ class _ArrowBatchIterator(RecordIterator):
self._batch.column(j)[self._idx].as_py()
for j in self._data_cols
)
- row = OffsetRow(row_tuple, 0, len(self._data_cols))
+ row = OffsetRow(
+ row_tuple, 0, len(self._data_cols),
+ file_io=self._file_io,
+ blob_field_indices=self._blob_field_indices,
+ descriptor_field_indices=self._descriptor_field_indices,
+ blob_view_lookup=self._blob_view_lookup,
+ vector_field_indices=self._vector_field_indices)
if self._has_rk:
from pypaimon.table.row.row_kind import RowKind
kind_str = self._batch.column(self._rk_idx)[self._idx].as_py()
@@ -146,6 +177,7 @@ class AuthFilterReader(RecordBatchReader):
batch = self._inner.read_arrow_batch()
if batch is None:
return None
+ self._refresh_blob_view_lookup(self._inner)
mask = self._filter_fn(batch)
return batch.filter(mask)
@@ -206,6 +238,7 @@ class AuthMaskingReader(RecordBatchReader):
batch = self._inner.read_arrow_batch()
if batch is None:
return None
+ self._refresh_blob_view_lookup(self._inner)
return apply_auth_masking(batch, self._parsed_rules)
def close(self):
@@ -223,6 +256,7 @@ class ColumnProjectReader(RecordBatchReader):
batch = self._inner.read_arrow_batch()
if batch is None:
return None
+ self._refresh_blob_view_lookup(self._inner)
columns = self._columns
if "_row_kind" in batch.schema.names and "_row_kind" not in columns:
columns = ["_row_kind"] + list(columns)
diff --git
a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
index 48cee27988..f7bc46612f 100644
--- a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
+++ b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
@@ -31,10 +31,9 @@ class BlobInlineConvertReader(RecordBatchReader):
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.
+ BlobViewStructs and bulk-preload their descriptors, then replace
+ view field values with descriptor bytes so Stage 2 can
+ materialize payloads with the originating table FileIO.
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
@@ -67,24 +66,27 @@ class BlobInlineConvertReader(RecordBatchReader):
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)
+ if not self._blob_as_descriptor:
+ # Stage 2 materializes descriptor/view fields to payload bytes.
+ # Row-level descriptor routing must not re-parse that content.
+ self.descriptor_field_indices = set()
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)
+ # Align with Java: only enter blob view resolution when catalog_loader
is available.
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 = {}
+ # Resolve view fields using the preloaded lookup.
+ view_blobs = {}
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)
+ batch, view_blobs = 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)
+ return self._resolve_descriptor_fields(batch, view_blobs)
# ------------------------------------------------------------------
# Stage 1: BlobView prescan (lightweight, only reads view columns)
@@ -125,33 +127,35 @@ class BlobInlineConvertReader(RecordBatchReader):
if all_view_structs:
self._blob_view_lookup = BlobViewLookup(self._table)
self._blob_view_lookup.preload(all_view_structs)
+ # Expose after prescan so OffsetRow.get_blob() can resolve each
+ # BlobViewStruct with the originating table FileIO.
+ self.blob_view_lookup = self._blob_view_lookup
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 = {}
+ view_blobs = {}
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 = []
+ field_blobs = []
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)
+ field_blobs.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)
+ field_blobs.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)
+ blob = blob_view_lookup.resolve_blob(view_struct)
+ converted_values.append(blob.to_descriptor().serialize())
+ field_blobs.append(blob)
column_idx = batch.schema.names.index(field_name)
batch = batch.set_column(
@@ -159,14 +163,14 @@ class BlobInlineConvertReader(RecordBatchReader):
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
+ view_blobs[field_name] = field_blobs
+ return batch, view_blobs
# ------------------------------------------------------------------
# Stage 2: BlobData resolution (unified exit)
# ------------------------------------------------------------------
- def _resolve_descriptor_fields(self, batch, view_file_ios=None):
+ def _resolve_descriptor_fields(self, batch, view_blobs=None):
if self._blob_as_descriptor:
return batch
@@ -174,7 +178,10 @@ class BlobInlineConvertReader(RecordBatchReader):
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]
+ blobs = [
+ self._descriptor_field_to_blob(value, self._table.file_io)
+ for value in values
+ ]
if self._blob_parallelism > 1:
converted_values = self._table.file_io.read_blobs_concurrent(
@@ -189,30 +196,16 @@ class BlobInlineConvertReader(RecordBatchReader):
pyarrow.array(converted_values, type=pyarrow.large_binary()),
)
- view_file_ios = view_file_ios or {}
+ view_blobs = view_blobs 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:
+ blobs = view_blobs.get(field_name)
+ if field_name not in batch.schema.names or blobs 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
+ if self._blob_parallelism > 1:
+ converted_values = self._table.file_io.read_blobs_concurrent(
+ blobs, self._blob_parallelism)
+ else:
+ converted_values = [blob.to_data() if blob else None for blob
in blobs]
column_idx = batch.schema.names.index(field_name)
batch = batch.set_column(
@@ -239,5 +232,19 @@ class BlobInlineConvertReader(RecordBatchReader):
value = bytes(value)
return value
+ @staticmethod
+ def _descriptor_field_to_blob(value, file_io):
+ if value is None:
+ return None
+ from pypaimon.common.uri_reader import UriReaderFactory
+
+ factory = (
+ UriReaderFactory.from_file_io(file_io) if file_io is not None else
None)
+ return Blob.from_descriptor_bytes(
+ value,
+ file_io=file_io,
+ uri_reader_factory=factory,
+ )
+
def close(self):
self._inner.close()
diff --git a/paimon-python/pypaimon/read/reader/blob_view_read_support.py
b/paimon-python/pypaimon/read/reader/blob_view_read_support.py
new file mode 100644
index 0000000000..98a7881b87
--- /dev/null
+++ b/paimon-python/pypaimon/read/reader/blob_view_read_support.py
@@ -0,0 +1,68 @@
+# 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.
+
+"""Helpers for eager blob-view/descriptor inline conversion on read."""
+
+from typing import List
+
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.read.reader.iface.record_reader import RecordReader
+from pypaimon.schema.data_types import DataField, PyarrowFieldParser
+
+
+def needs_blob_inline_convert(table) -> bool:
+ view_fields = CoreOptions.blob_view_fields(table.options)
+ descriptor_fields = CoreOptions.blob_descriptor_fields(table.options)
+ if descriptor_fields:
+ # Materialize when blob-as-descriptor=false; otherwise still wrap so
+ # merge to_iterator()+get_blob() receives descriptor field metadata.
+ return True
+ if not view_fields:
+ return False
+ if CoreOptions.blob_as_descriptor(table.options):
+ return True
+ return CoreOptions.blob_view_resolve_enabled(table.options)
+
+
+def wrap_record_reader_with_blob_inline_convert(
+ reader: RecordReader,
+ split_read,
+ read_fields: List[DataField],
+) -> RecordReader:
+ from pypaimon.read.reader.auth_masking_reader import (
+ BatchToRecordReaderAdapter, RecordReaderToBatchAdapter)
+ from pypaimon.read.reader.blob_descriptor_convert_reader import
BlobInlineConvertReader
+ from pypaimon.read.reader.field_indices import (
+ blob_field_indices, descriptor_field_indices_for_table,
vector_field_indices)
+
+ schema = PyarrowFieldParser.from_paimon_schema(read_fields)
+ # Internal round-trip must keep RowKind; default adapter omits _row_kind
+ # and BatchToRecordReaderAdapter would then emit OffsetRow byte 1 (-U).
+ batch_reader = RecordReaderToBatchAdapter(
+ reader, schema, include_row_kind=True)
+ batch_reader.file_io = split_read.table.file_io
+ batch_reader.blob_field_indices = blob_field_indices(read_fields)
+ batch_reader.descriptor_field_indices = descriptor_field_indices_for_table(
+ split_read.table, read_fields)
+ batch_reader.vector_field_indices = vector_field_indices(read_fields)
+ batch_reader = BlobInlineConvertReader(
+ batch_reader,
+ split_read.table,
+ prescan_reader_factory=lambda names:
split_read._create_blob_view_prescan_reader(names),
+ blob_parallelism=split_read._blob_parallelism,
+ )
+ return BatchToRecordReaderAdapter(batch_reader)
diff --git a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
index b1ddd63043..059253d606 100644
--- a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
@@ -110,11 +110,15 @@ class _BlobFileState:
class ConcatBatchReader(RecordBatchReader):
def __init__(self, reader_suppliers: List[Callable], file_io=None,
- blob_field_indices=None, vector_field_indices=None):
+ blob_field_indices=None, vector_field_indices=None,
+ descriptor_field_indices=None,
+ blob_view_lookup=None):
self.queue: collections.deque[Callable] =
collections.deque(reader_suppliers)
self.current_reader: Optional[RecordBatchReader] = None
self.file_io = file_io
self.blob_field_indices = blob_field_indices
+ self.descriptor_field_indices = descriptor_field_indices
+ self.blob_view_lookup = blob_view_lookup
self.vector_field_indices = vector_field_indices
def read_arrow_batch(self) -> Optional[RecordBatch]:
diff --git a/paimon-python/pypaimon/read/reader/deferred_blob_resolve_reader.py
b/paimon-python/pypaimon/read/reader/deferred_blob_resolve_reader.py
index 9056f00dcb..2108d92012 100644
--- a/paimon-python/pypaimon/read/reader/deferred_blob_resolve_reader.py
+++ b/paimon-python/pypaimon/read/reader/deferred_blob_resolve_reader.py
@@ -43,6 +43,7 @@ class DeferredBlobResolveReader(RecordBatchReader):
batch = self._inner.read_arrow_batch()
if batch is None:
return None
+ self._refresh_blob_view_lookup(self._inner)
columns = list(batch.columns)
fields = list(batch.schema)
@@ -52,6 +53,10 @@ class DeferredBlobResolveReader(RecordBatchReader):
if column_index < 0:
continue
values = batch.column(column_index).to_pylist()
+ # Dedicated .blob files live on the table filesystem. Pass the
+ # table FileIO so REST tokens apply. Catalog
UriReaderFactory.create
+ # would open an unscoped FileIO; from_file_io is unnecessary here
+ # because these values are already table-local blob payloads.
blobs = [Blob.from_bytes(value, self._file_io) for value in values]
if self._blob_parallelism > 1:
payloads = self._file_io.read_blobs_concurrent(
diff --git a/paimon-python/pypaimon/read/reader/field_indices.py
b/paimon-python/pypaimon/read/reader/field_indices.py
index 02060a2f51..78180e707b 100644
--- a/paimon-python/pypaimon/read/reader/field_indices.py
+++ b/paimon-python/pypaimon/read/reader/field_indices.py
@@ -29,6 +29,28 @@ def blob_field_indices(fields: List[DataField]) -> Set[int]:
}
+def descriptor_field_indices(
+ fields: List[DataField], descriptor_field_names: Iterable[str]) ->
Set[int]:
+ names = set(descriptor_field_names)
+ if not names:
+ return set()
+ return {i for i, f in enumerate(fields) if f.name in names}
+
+
+def descriptor_field_names_for_table(table) -> Set[str]:
+ from pypaimon.common.options.core_options import CoreOptions
+
+ names = set(CoreOptions.blob_descriptor_fields(table.options))
+ if CoreOptions.blob_as_descriptor(table.options):
+ names |= CoreOptions.blob_view_fields(table.options)
+ return names
+
+
+def descriptor_field_indices_for_table(table, fields: List[DataField]) ->
Set[int]:
+ return descriptor_field_indices(
+ fields, descriptor_field_names_for_table(table))
+
+
def vector_field_indices(fields: List[DataField]) -> Set[int]:
return {i for i, f in enumerate(fields) if isinstance(f.type, VectorType)}
diff --git a/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
b/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
index fdebbc99cb..2100dd9361 100644
--- a/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
@@ -59,6 +59,7 @@ class FilterRecordBatchReader(RecordBatchReader):
batch = self.reader.read_arrow_batch()
if batch is None:
return None
+ self._refresh_blob_view_lookup(self.reader)
if batch.num_rows == 0:
return batch
filtered = self._filter_batch(batch)
@@ -98,6 +99,8 @@ class FilterRecordBatchReader(RecordBatchReader):
self.file_io,
self.blob_field_indices,
self.vector_field_indices,
+ self.descriptor_field_indices,
+ self.blob_view_lookup,
)
selected = []
pos = 0
diff --git a/paimon-python/pypaimon/read/reader/filter_record_reader.py
b/paimon-python/pypaimon/read/reader/filter_record_reader.py
index 919b9e42e0..11d2407c80 100644
--- a/paimon-python/pypaimon/read/reader/filter_record_reader.py
+++ b/paimon-python/pypaimon/read/reader/filter_record_reader.py
@@ -31,11 +31,13 @@ class FilterRecordReader(RecordReader[InternalRow]):
def __init__(self, reader: RecordReader[InternalRow], predicate:
Predicate):
self.reader = reader
self.predicate = predicate
+ self._adopt_blob_metadata(reader)
def read_batch(self) -> Optional[RecordIterator[InternalRow]]:
iterator = self.reader.read_batch()
if iterator is None:
return None
+ self._refresh_blob_view_lookup(self.reader)
return FilterRecordIterator(iterator, self.predicate)
def close(self) -> None:
diff --git a/paimon-python/pypaimon/read/reader/iface/record_batch_reader.py
b/paimon-python/pypaimon/read/reader/iface/record_batch_reader.py
index 7888f2fab5..a87b059a21 100644
--- a/paimon-python/pypaimon/read/reader/iface/record_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/iface/record_batch_reader.py
@@ -36,11 +36,16 @@ class RecordBatchReader(RecordReader):
file_io = None
blob_field_indices = None
+ descriptor_field_indices = None
+ blob_view_lookup = None
vector_field_indices = None
def _adopt_metadata(self, reader: "RecordBatchReader") -> None:
self.file_io = reader.file_io
self.blob_field_indices = reader.blob_field_indices
+ self.descriptor_field_indices = getattr(
+ reader, 'descriptor_field_indices', None)
+ self.blob_view_lookup = getattr(reader, 'blob_view_lookup', None)
self.vector_field_indices = reader.vector_field_indices
@abstractmethod
@@ -73,7 +78,8 @@ class RecordBatchReader(RecordReader):
return None
return InternalRowWrapperIterator(
self._iter_df_rows(df), df.width, self.file_io,
- self.blob_field_indices, self.vector_field_indices)
+ self.blob_field_indices, self.vector_field_indices,
+ self.descriptor_field_indices, self.blob_view_lookup)
@staticmethod
def _iter_df_rows(df) -> Iterator[tuple]:
@@ -87,12 +93,16 @@ class RecordBatchReader(RecordReader):
class InternalRowWrapperIterator(RecordIterator[InternalRow]):
def __init__(self, iterator: Iterator[tuple], width: int,
file_io=None, blob_field_indices=None,
- vector_field_indices=None):
+ vector_field_indices=None,
+ descriptor_field_indices=None,
+ blob_view_lookup=None):
self._iterator = iterator
self._reused_row = OffsetRow(None, 0, width,
file_io=file_io,
blob_field_indices=blob_field_indices,
- vector_field_indices=vector_field_indices)
+ vector_field_indices=vector_field_indices,
+
descriptor_field_indices=descriptor_field_indices,
+ blob_view_lookup=blob_view_lookup)
def next(self) -> Optional[InternalRow]:
row_tuple = next(self._iterator, None)
@@ -114,6 +124,7 @@ class RowPositionReader(RecordBatchReader):
batch = self._data_reader.read_arrow_batch()
if batch is None:
return None
+ self._refresh_blob_view_lookup(self._data_reader)
self.batch_pos += batch.num_rows
return batch
diff --git a/paimon-python/pypaimon/read/reader/iface/record_reader.py
b/paimon-python/pypaimon/read/reader/iface/record_reader.py
index 2b44955629..d777994c6d 100644
--- a/paimon-python/pypaimon/read/reader/iface/record_reader.py
+++ b/paimon-python/pypaimon/read/reader/iface/record_reader.py
@@ -39,3 +39,18 @@ class RecordReader(Generic[T], ABC):
"""
Closes the reader and should release all resources.
"""
+
+ def _adopt_blob_metadata(self, reader) -> None:
+ self.file_io = getattr(reader, 'file_io', None)
+ self.blob_field_indices = getattr(reader, 'blob_field_indices', None)
+ self.descriptor_field_indices = getattr(reader,
'descriptor_field_indices', None)
+ self.blob_view_lookup = getattr(reader, 'blob_view_lookup', None)
+ self.vector_field_indices = getattr(reader, 'vector_field_indices',
None)
+
+ def _refresh_blob_view_lookup(self, reader) -> None:
+ # BlobInlineConvertReader fills lookup during the first prescan, after
+ # wrappers have already copied metadata in __init__. Descriptor indices
+ # are known at construction and must not be overwritten: the inner
+ # reader may still hold None or unprojected coordinates.
+ self.blob_view_lookup = getattr(
+ reader, 'blob_view_lookup', self.blob_view_lookup)
diff --git a/paimon-python/pypaimon/read/reader/limited_record_reader.py
b/paimon-python/pypaimon/read/reader/limited_record_reader.py
index a4eab01986..19565c1153 100644
--- a/paimon-python/pypaimon/read/reader/limited_record_reader.py
+++ b/paimon-python/pypaimon/read/reader/limited_record_reader.py
@@ -44,6 +44,7 @@ class LimitedRecordReader(RecordReader):
# Public so the iterator can read/write the shared counter without
# going through accessor calls per row.
self.count = 0
+ self._adopt_blob_metadata(inner)
def read_batch(self) -> Optional[RecordIterator]:
if self.count >= self._limit:
@@ -51,6 +52,7 @@ class LimitedRecordReader(RecordReader):
batch = self._inner.read_batch()
if batch is None:
return None
+ self._refresh_blob_view_lookup(self._inner)
return _LimitedRecordIterator(batch, self)
def close(self) -> None:
@@ -96,6 +98,7 @@ class LimitedRecordBatchReader(RecordBatchReader):
batch = self._inner.read_arrow_batch()
if batch is None:
return None
+ self._refresh_blob_view_lookup(self._inner)
remaining = self._limit - self.count
if batch.num_rows > remaining:
batch = batch.slice(0, remaining)
diff --git a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
index 59bf7def73..6bb1d6450d 100644
--- a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
@@ -23,7 +23,8 @@ from pyarrow import RecordBatch
from pypaimon.data.map_shared_shredding import \
assemble_normal_map_selected_keys
-from pypaimon.read.reader.field_indices import blob_field_indices,
vector_field_indices
+from pypaimon.read.reader.field_indices import (
+ blob_field_indices, descriptor_field_indices, vector_field_indices)
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
from pypaimon.schema.data_types import DataField, PyarrowFieldParser
@@ -48,7 +49,8 @@ class NestedLeafBatchReader(RecordBatchReader):
"""
def __init__(self, inner: RecordBatchReader, name_paths: List[List[str]],
- output_fields: List[DataField]):
+ output_fields: List[DataField],
+ descriptor_field_names=None):
if len(name_paths) != len(output_fields):
raise ValueError(
"name_paths length {} does not match output_fields length
{}".format(
@@ -58,6 +60,8 @@ class NestedLeafBatchReader(RecordBatchReader):
self._schema = PyarrowFieldParser.from_paimon_schema(output_fields)
self.file_io = inner.file_io
self.blob_field_indices = blob_field_indices(output_fields)
+ self.descriptor_field_indices = descriptor_field_indices(
+ output_fields, descriptor_field_names or ())
self.vector_field_indices = vector_field_indices(output_fields)
def read_arrow_batch(self) -> Optional[RecordBatch]:
diff --git
a/paimon-python/pypaimon/read/reader/outer_projection_record_reader.py
b/paimon-python/pypaimon/read/reader/outer_projection_record_reader.py
index e8bb475097..90a402b659 100644
--- a/paimon-python/pypaimon/read/reader/outer_projection_record_reader.py
+++ b/paimon-python/pypaimon/read/reader/outer_projection_record_reader.py
@@ -45,6 +45,7 @@ class OuterProjectionRecordReader(RecordReader[InternalRow]):
file_io=None,
blob_field_indices=None,
vector_field_indices=None,
+ descriptor_field_indices=None,
):
if not name_paths:
raise ValueError("name_paths must be non-empty")
@@ -65,16 +66,26 @@ class
OuterProjectionRecordReader(RecordReader[InternalRow]):
self._file_io = file_io
self._blob_field_indices = project_top_level_field_indices(
blob_field_indices, self._specs)
+ self._descriptor_field_indices = project_top_level_field_indices(
+ descriptor_field_indices, self._specs)
self._vector_field_indices = project_top_level_field_indices(
vector_field_indices, self._specs)
+ self.file_io = self._file_io
+ self.blob_field_indices = self._blob_field_indices
+ self.descriptor_field_indices = self._descriptor_field_indices
+ self.vector_field_indices = self._vector_field_indices
+ self.blob_view_lookup = getattr(inner, 'blob_view_lookup', None)
def read_batch(self) -> Optional[RecordIterator[InternalRow]]:
inner_batch = self._inner.read_batch()
if inner_batch is None:
return None
+ self._refresh_blob_view_lookup(self._inner)
return _OuterProjectionIterator(
inner_batch, self._specs, self._flat_arity, self._file_io,
- self._blob_field_indices, self._vector_field_indices)
+ self._blob_field_indices, self._vector_field_indices,
+ self._descriptor_field_indices,
+ blob_view_lookup=self.blob_view_lookup)
def close(self) -> None:
self._inner.close()
@@ -91,6 +102,8 @@ class _OuterProjectionIterator(RecordIterator[InternalRow]):
file_io=None,
blob_field_indices=None,
vector_field_indices=None,
+ descriptor_field_indices=None,
+ blob_view_lookup=None,
):
self._inner = inner
self._specs = specs
@@ -98,7 +111,9 @@ class _OuterProjectionIterator(RecordIterator[InternalRow]):
self._reused_row = OffsetRow(None, 0, flat_arity,
file_io=file_io,
blob_field_indices=blob_field_indices,
- vector_field_indices=vector_field_indices)
+ vector_field_indices=vector_field_indices,
+
descriptor_field_indices=descriptor_field_indices,
+ blob_view_lookup=blob_view_lookup)
def next(self) -> Optional[InternalRow]:
inner_row = self._inner.next()
diff --git
a/paimon-python/pypaimon/read/reader/row_range_filter_record_reader.py
b/paimon-python/pypaimon/read/reader/row_range_filter_record_reader.py
index d25434f396..2d7590b4e0 100644
--- a/paimon-python/pypaimon/read/reader/row_range_filter_record_reader.py
+++ b/paimon-python/pypaimon/read/reader/row_range_filter_record_reader.py
@@ -39,6 +39,7 @@ class RowIdFilterRecordBatchReader(RecordBatchReader):
batch = self.reader.read_arrow_batch()
if batch is None:
return None
+ self._refresh_blob_view_lookup(self.reader)
if batch.num_rows == 0:
return batch
import numpy as np
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index 71b34b2853..1932852563 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -56,10 +56,13 @@ from pypaimon.read.reader.drop_delete_reader import
DropDeleteRecordReader
from pypaimon.read.reader.empty_record_reader import EmptyFileRecordReader
from pypaimon.read.reader.field_bunch import BlobBunch, DataBunch, FieldBunch,
VectorBunch
from pypaimon.read.reader.field_indices import (
- blob_field_indices, vector_field_indices)
+ blob_field_indices, descriptor_field_indices_for_table,
+ descriptor_field_names_for_table, vector_field_indices)
from pypaimon.read.reader.filter_record_reader import FilterRecordReader
from pypaimon.read.reader.format_avro_reader import FormatAvroReader
from pypaimon.read.reader.blob_descriptor_convert_reader import
BlobInlineConvertReader
+from pypaimon.read.reader.blob_view_read_support import (
+ needs_blob_inline_convert, wrap_record_reader_with_blob_inline_convert)
from pypaimon.read.reader.filter_record_batch_reader import
FilterRecordBatchReader
from pypaimon.read.reader.limited_record_reader import
LimitedRecordBatchReader, LimitedRecordReader
from pypaimon.read.reader.row_range_filter_record_reader import
RowIdFilterRecordBatchReader
@@ -191,6 +194,34 @@ class SplitRead(ABC):
)
else:
self.predicate_for_reader = None
+ self._blob_view_prescan = False
+
+ def _needs_blob_inline_convert(self) -> bool:
+ return needs_blob_inline_convert(self.table)
+
+ def _wrap_batch_reader_with_blob_inline_convert(
+ self, reader: RecordBatchReader) -> RecordBatchReader:
+ if not self._needs_blob_inline_convert() or self._blob_view_prescan:
+ return reader
+ return BlobInlineConvertReader(
+ reader,
+ self.table,
+ prescan_reader_factory=lambda names:
self._create_blob_view_prescan_reader(names),
+ blob_parallelism=self._blob_parallelism,
+ )
+
+ def _blob_view_prescan_limit(self) -> Optional[int]:
+ # Prescan only projects view columns. A predicate/auth filter selects a
+ # different first-N than LIMIT alone, so do not cap the prescan; the
+ # outer reader still applies LIMIT after filtering.
+ if self.predicate is not None:
+ return None
+ if getattr(self, '_post_merge_filter', None) is not None:
+ return None
+ return self.limit
+
+ def _create_blob_view_prescan_reader(self, field_names: set):
+ raise NotImplementedError
def _compute_nested_path_by_name(self) -> Optional[Dict[str, List[str]]]:
if not self.nested_name_paths:
@@ -914,7 +945,8 @@ class RawFileSplitRead(SplitRead):
row_tracking_enabled: bool,
outer_extract_name_paths: Optional[List[List[str]]] = None,
outer_flat_read_type: Optional[List[DataField]] = None,
- limit: Optional[int] = None):
+ limit: Optional[int] = None,
+ _blob_view_prescan: bool = False):
self._physical_row_ranges = {}
actual_split = split
if isinstance(split, IndexedSplit):
@@ -938,9 +970,28 @@ class RawFileSplitRead(SplitRead):
row_tracking_enabled=row_tracking_enabled,
nested_name_paths=None,
limit=limit)
+ self._blob_view_prescan = _blob_view_prescan
self.outer_extract_name_paths = outer_extract_name_paths
self.outer_flat_read_type = outer_flat_read_type
+ def _create_blob_view_prescan_reader(self, field_names: set):
+ prescan_fields = [f for f in self.read_fields if f.name in field_names]
+ if not prescan_fields:
+ return EmptyRecordBatchReader()
+ prescan_read = RawFileSplitRead(
+ table=self.table,
+ predicate=self.predicate,
+ read_type=prescan_fields,
+ split=self.split,
+ row_tracking_enabled=False,
+ limit=self._blob_view_prescan_limit(),
+ _blob_view_prescan=True,
+ )
+ # self.split is already unwrapped from IndexedSplit. Keep its physical
+ # selection so LIMIT preloads the same views as the main reader.
+ prescan_read._physical_row_ranges = self._physical_row_ranges
+ return prescan_read.create_reader()
+
def raw_reader_supplier(self, file: DataFileMeta, dv_factory:
Optional[Callable] = None) -> Optional[RecordReader]:
read_fields = self._get_final_read_data_fields()
physical_row_ranges = getattr(
@@ -1006,6 +1057,8 @@ class RawFileSplitRead(SplitRead):
concat_reader = ConcatBatchReader(
data_readers, file_io=self.table.file_io,
blob_field_indices=blob_field_indices(self.read_fields),
+ descriptor_field_indices=descriptor_field_indices_for_table(
+ self.table, self.read_fields),
vector_field_indices=vector_field_indices(self.read_fields))
reader = concat_reader
if (self.predicate_for_reader
@@ -1026,7 +1079,10 @@ class RawFileSplitRead(SplitRead):
NestedLeafBatchReader
reader = NestedLeafBatchReader(
reader, self.outer_extract_name_paths,
- self.outer_flat_read_type)
+ self.outer_flat_read_type,
+ descriptor_field_names=(
+ descriptor_field_names_for_table(self.table)
+ or None))
# A predicate on a projected nested leaf cannot be pushed down:
# its leaf path is absent from the widened top-level read fields,
# so SplitRead.__init__ dropped it (predicate_for_reader is None).
@@ -1040,7 +1096,7 @@ class RawFileSplitRead(SplitRead):
reader = FilterRecordBatchReader(reader, trimmed)
if self.limit is not None:
reader = LimitedRecordBatchReader(reader, self.limit)
- return reader
+ return self._wrap_batch_reader_with_blob_inline_convert(reader)
def _all_data_fields_from(self, fields):
if self.row_tracking_enabled:
@@ -1058,7 +1114,8 @@ class MergeFileSplitRead(SplitRead):
row_tracking_enabled: bool,
outer_extract_name_paths: Optional[List[List[str]]] = None,
outer_flat_read_type: Optional[List[DataField]] = None,
- limit: Optional[int] = None):
+ limit: Optional[int] = None,
+ _blob_view_prescan: bool = False):
self.row_ranges = None
if isinstance(split, IndexedSplit):
self.row_ranges = split.row_ranges()
@@ -1077,6 +1134,7 @@ class MergeFileSplitRead(SplitRead):
)
self.outer_extract_name_paths = outer_extract_name_paths
self.outer_flat_read_type = outer_flat_read_type
+ self._blob_view_prescan = _blob_view_prescan
# Built once per split-read (value_fields and options are constant
# for the object's life), not per section. ``None`` when
# ``sequence.field`` is unset, in which case the heap falls back to
@@ -1163,6 +1221,65 @@ class MergeFileSplitRead(SplitRead):
value_field_names=[f.name for f in self.value_fields],
)
+ def _outer_reapplies_predicate_after_projection(self) -> bool:
+ return (
+ bool(self.outer_extract_name_paths)
+ and self.predicate is not None
+ and self.predicate_for_reader is None
+ and self.outer_flat_read_type is not None
+ )
+
+ def _blob_view_prescan_read_type(self, field_names: set):
+ """View columns plus any ``sequence.field`` needed to merge
overlapping files.
+
+ TableRead injects missing sequence fields into the main merge
+ projection; prescan must do the same or ``builtin_seq_comparator``
+ raises ``sequence.field 'ts' not found in value fields ['pic']``.
+ """
+ value_fields = self.read_fields[-self.value_arity:]
+ prescan_fields = [f for f in value_fields if f.name in field_names]
+ if not prescan_fields:
+ return []
+ seq_names = self.table.options.sequence_field()
+ if not seq_names:
+ return prescan_fields
+ present = {f.name for f in prescan_fields}
+ for field in value_fields:
+ if field.name in seq_names and field.name not in present:
+ prescan_fields.append(field)
+ present.add(field.name)
+ missing = [name for name in seq_names if name not in present]
+ if missing:
+ table_fields_by_name = {f.name: f for f in self.table.fields}
+ for name in missing:
+ field = table_fields_by_name.get(name)
+ if field is None:
+ raise ValueError(
+ "sequence.field %r not found in table schema" %
(name,))
+ prescan_fields.append(field)
+ return prescan_fields
+
+ def _create_blob_view_prescan_reader(self, field_names: set):
+ prescan_fields = self._blob_view_prescan_read_type(field_names)
+ if not prescan_fields:
+ return EmptyRecordBatchReader()
+ prescan_read = MergeFileSplitRead(
+ table=self.table,
+ predicate=self.predicate,
+ read_type=prescan_fields,
+ split=self.split,
+ row_tracking_enabled=False,
+ limit=self._blob_view_prescan_limit(),
+ _blob_view_prescan=True,
+ )
+ prescan_read.row_ranges = self.row_ranges
+ reader = prescan_read.create_reader()
+ if isinstance(reader, RecordBatchReader):
+ return reader
+ from pypaimon.read.reader.auth_masking_reader import
RecordReaderToBatchAdapter
+ schema = PyarrowFieldParser.from_paimon_schema(prescan_fields)
+ return RecordReaderToBatchAdapter(reader, schema)
+
def create_reader(self) -> RecordReader:
self._genarate_deletion_file_readers()
if getattr(self.split, 'is_streaming', False):
@@ -1181,16 +1298,34 @@ class MergeFileSplitRead(SplitRead):
reader = FilterRecordReader(kv_unwrap_reader,
self.predicate_for_reader)
else:
reader = kv_unwrap_reader
+ value_fields = self.read_fields[-self.value_arity:]
+ # Apply LIMIT before inline convert so BlobView prescan and the main
+ # adapter consume the same N rows. Nested-leaf predicates are
re-applied
+ # after outer projection and can drop rows, so keep LIMIT outermost
then.
+ limit_before_convert = (
+ self.limit is not None
+ and not self._outer_reapplies_predicate_after_projection()
+ )
+ if limit_before_convert:
+ reader = LimitedRecordReader(reader, self.limit)
+ if self._needs_blob_inline_convert() and not self._blob_view_prescan:
+ reader = wrap_record_reader_with_blob_inline_convert(
+ reader, self, value_fields)
if self.outer_extract_name_paths:
from pypaimon.read.reader.outer_projection_record_reader import \
OuterProjectionRecordReader
inner_value_fields = self.read_fields[-self.value_arity:]
+ inner_descriptor_indices = getattr(reader,
'descriptor_field_indices', None)
+ if inner_descriptor_indices is None:
+ inner_descriptor_indices = descriptor_field_indices_for_table(
+ self.table, inner_value_fields)
reader = OuterProjectionRecordReader(
reader, [f.name for f in inner_value_fields],
self.outer_extract_name_paths,
file_io=self.table.file_io,
blob_field_indices=blob_field_indices(inner_value_fields),
- vector_field_indices=vector_field_indices(inner_value_fields))
+ vector_field_indices=vector_field_indices(inner_value_fields),
+ descriptor_field_indices=inner_descriptor_indices)
# A predicate on a projected nested leaf is not pushed down (its
leaf
# path is absent from the widened-to-full-ROW read fields, so it
was
# dropped in __init__). Without re-applying it after extraction the
@@ -1206,7 +1341,7 @@ class MergeFileSplitRead(SplitRead):
reader,
rewrite_predicate_indices(
trimmed, self.outer_flat_read_type))
- if self.limit is not None:
+ if self.limit is not None and not limit_before_convert:
reader = LimitedRecordReader(reader, self.limit)
return reader
@@ -1255,16 +1390,7 @@ class DataEvolutionSplitRead(SplitRead):
def create_reader(self) -> RecordReader:
reader = self._create_raw_reader()
-
- if ((CoreOptions.blob_view_fields(self.table.options) and
CoreOptions.blob_view_resolve_enabled(
- self.table.options))
- or (not CoreOptions.blob_as_descriptor(self.table.options)
- and
CoreOptions.blob_descriptor_fields(self.table.options))):
- blob_parallelism = self._blob_parallelism
- reader = BlobInlineConvertReader(
- reader, self.table,
- prescan_reader_factory=lambda names:
self._create_prescan_reader(names),
- blob_parallelism=blob_parallelism)
+ reader = self._wrap_batch_reader_with_blob_inline_convert(reader)
if self._post_filter_after_inline:
if self._post_merge_filter is not None:
@@ -1320,6 +1446,8 @@ class DataEvolutionSplitRead(SplitRead):
merge_reader = ConcatBatchReader(
suppliers, file_io=self.table.file_io,
blob_field_indices=blob_field_indices(self.read_fields),
+ descriptor_field_indices=descriptor_field_indices_for_table(
+ self.table, self.read_fields),
vector_field_indices=vector_field_indices(self.read_fields))
if self.predicate_for_reader is not None:
reader = FilterRecordBatchReader(
@@ -1342,7 +1470,11 @@ class DataEvolutionSplitRead(SplitRead):
from pypaimon.read.reader.nested_leaf_batch_reader import \
NestedLeafBatchReader
reader = NestedLeafBatchReader(
- reader, self.outer_extract_name_paths,
self.outer_flat_read_type)
+ reader, self.outer_extract_name_paths,
self.outer_flat_read_type,
+ descriptor_field_names=(
+ descriptor_field_names_for_table(self.table)
+ or None))
+
if self.limit is not None and not self._post_filter_after_inline:
reader = LimitedRecordBatchReader(reader, self.limit)
@@ -1393,6 +1525,9 @@ class DataEvolutionSplitRead(SplitRead):
for row_id in range(row_range.from_, row_range.to + 1)
]
+ def _create_blob_view_prescan_reader(self, field_names: set):
+ return self._create_prescan_reader(field_names)
+
def _create_prescan_reader(self, field_names):
"""Create a prescan reader by constructing a new DataEvolutionSplitRead
instance that only projects the specified field names.
@@ -1406,16 +1541,13 @@ class DataEvolutionSplitRead(SplitRead):
if not prescan_fields:
return EmptyRecordBatchReader()
- # Skip limit push-down when the outer reader also selects rows
(predicate or auth
- # filter): prescan's first-N rows would differ from the outer set.
TODO: push down.
- skip_limit = self.predicate is not None or self._post_merge_filter is
not None
prescan_read = DataEvolutionSplitRead(
table=self.table,
predicate=self.predicate,
read_type=prescan_fields,
split=self.split,
row_tracking_enabled=False,
- limit=None if skip_limit else self.limit,
+ limit=self._blob_view_prescan_limit(),
)
prescan_read.row_ranges = self.row_ranges
return prescan_read._create_raw_reader()
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index 48b8c1127c..34bed8aa97 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -1688,6 +1688,15 @@ class TableRead:
if not isinstance(reader, RecordBatchReader):
schema = PyarrowFieldParser.from_paimon_schema(effective_read_type)
reader = RecordReaderToBatchAdapter(reader, schema,
include_row_kind=self.include_row_kind)
+ if getattr(reader, 'blob_field_indices', None) is None:
+ from pypaimon.read.reader.field_indices import (
+ blob_field_indices, descriptor_field_indices_for_table,
+ vector_field_indices)
+ reader.file_io = self.table.file_io
+ reader.blob_field_indices =
blob_field_indices(effective_read_type)
+ reader.descriptor_field_indices =
descriptor_field_indices_for_table(
+ self.table, effective_read_type)
+ reader.vector_field_indices =
vector_field_indices(effective_read_type)
needs_convert_back = True
if filter_fn and not embed_filter:
diff --git a/paimon-python/pypaimon/table/row/offset_row.py
b/paimon-python/pypaimon/table/row/offset_row.py
index 4ac8b7dfa1..4e0255f969 100644
--- a/paimon-python/pypaimon/table/row/offset_row.py
+++ b/paimon-python/pypaimon/table/row/offset_row.py
@@ -25,7 +25,9 @@ class OffsetRow(InternalRow):
def __init__(self, row_tuple: Optional[tuple], offset: int, arity: int,
file_io=None, blob_field_indices: Optional[Iterable[int]] =
None,
- vector_field_indices: Optional[Iterable[int]] = None):
+ vector_field_indices: Optional[Iterable[int]] = None,
+ descriptor_field_indices: Optional[Iterable[int]] = None,
+ blob_view_lookup=None):
self.row_tuple = row_tuple
self.offset = offset
self.arity = arity
@@ -34,6 +36,11 @@ class OffsetRow(InternalRow):
self._blob_field_indices: FrozenSet[int] = (
frozenset(blob_field_indices) if blob_field_indices is not None
else frozenset()
)
+ self._descriptor_field_indices: FrozenSet[int] = (
+ frozenset(descriptor_field_indices)
+ if descriptor_field_indices is not None else frozenset()
+ )
+ self._blob_view_lookup = blob_view_lookup
self._vector_field_indices: FrozenSet[int] = (
frozenset(vector_field_indices) if vector_field_indices is not
None else frozenset()
)
@@ -55,12 +62,55 @@ class OffsetRow(InternalRow):
raise IndexError(f"Position {pos} is out of bounds for row arity
{self.arity}")
return self.row_tuple[self.offset + pos]
- def get_blob(self, pos: int):
+ @staticmethod
+ def _normalize_blob_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 _resolve_blob_view_struct(self, view_struct):
from pypaimon.table.row.blob import Blob
+ if self._blob_view_lookup is not None:
+ if self._blob_view_lookup.resolve_to_null(view_struct):
+ return None
+ return self._blob_view_lookup.resolve_blob(view_struct)
+ return Blob.from_view(view_struct)
+
+ def _blob_from_descriptor_field_bytes(self, raw: bytes):
+ from pypaimon.table.row.blob import Blob
+
+ return Blob.from_descriptor_bytes(
+ raw, self._file_io, uri_reader_factory=self._uri_reader_factory())
+
+ def _uri_reader_factory(self):
+ if self._file_io is None:
+ return None
+ from pypaimon.common.uri_reader import UriReaderFactory
+
+ return UriReaderFactory.from_file_io(self._file_io)
+
+ def get_blob(self, pos: int):
+ from pypaimon.table.row.blob import Blob, BlobViewStruct
+
if pos not in self._blob_field_indices:
raise TypeError(f"Field at position {pos} is not a BLOB field")
- return Blob.from_bytes(self.get_field(pos), self._file_io)
+ value = self.get_field(pos)
+ if value is None:
+ return None
+ raw = self._normalize_blob_bytes(value)
+ if raw is not None and BlobViewStruct.is_blob_view_struct(raw):
+ return
self._resolve_blob_view_struct(BlobViewStruct.deserialize(raw))
+ if pos in self._descriptor_field_indices:
+ return self._blob_from_descriptor_field_bytes(raw)
+ return Blob.from_bytes(
+ raw, self._file_io, uri_reader_factory=self._uri_reader_factory())
def get_vector(self, pos: int):
from pypaimon.table.row.vector import Vector
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py
b/paimon-python/pypaimon/tests/blob_table_test.py
index c012f317b4..9adb80e1ac 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -5390,6 +5390,209 @@ class DedicatedFormatWriterTest(unittest.TestCase):
self.assertEqual(result.num_rows, 1)
self.assertEqual(result.column('picture').to_pylist()[0], payload)
+ def test_blob_view_predicate_and_limit_resolves_filtered_row(self):
+ """Predicate + LIMIT must not restrict prescan to the unfiltered
first-N.
+
+ The matching row can sit past LIMIT in file order; prescan has to
+ preload that view or convert fails with a missing BlobViewStruct.
+ """
+ from pypaimon import Schema
+ from pypaimon.table.row.blob import BlobViewStruct
+
+ source_schema = pa.schema([
+ ('id', pa.int32()),
+ ('picture', pa.large_binary()),
+ ])
+ source = Schema.from_pyarrow_schema(
+ source_schema,
+ options={
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ }
+ )
+ self.catalog.create_table(
+ 'test_db.blob_view_pred_limit_source', source, False)
+ source_table = self.catalog.get_table(
+ 'test_db.blob_view_pred_limit_source')
+
+ num_rows = 10
+ payloads = [f'payload-{i}'.encode() for i in range(num_rows)]
+ write_builder = source_table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ writer.write_arrow(pa.Table.from_pydict({
+ 'id': list(range(num_rows)),
+ 'picture': payloads,
+ }, schema=source_schema))
+ write_builder.new_commit().commit(writer.prepare_commit())
+ writer.close()
+
+ picture_field_id = next(
+ field.id for field in source_table.table_schema.fields
+ if field.name == 'picture'
+ )
+ view_values = [
+ BlobViewStruct(
+ 'test_db.blob_view_pred_limit_source', picture_field_id, i
+ ).serialize()
+ for i in range(num_rows)
+ ]
+
+ target_schema = pa.schema([
+ ('id', pa.int32()),
+ ('picture', pa.large_binary()),
+ ])
+ target = Schema.from_pyarrow_schema(
+ target_schema,
+ options={
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ 'blob-view-field': 'picture',
+ }
+ )
+ self.catalog.create_table(
+ 'test_db.blob_view_pred_limit_target', target, False)
+ target_table = self.catalog.get_table(
+ 'test_db.blob_view_pred_limit_target')
+
+ target_write_builder = target_table.new_batch_write_builder()
+ target_writer = target_write_builder.new_write()
+ target_writer.write_arrow(pa.Table.from_pydict({
+ 'id': list(range(num_rows)),
+ 'picture': view_values,
+ }, schema=target_schema))
+ target_write_builder.new_commit().commit(
+ target_writer.prepare_commit())
+ target_writer.close()
+
+ read_builder = target_table.new_read_builder()
+ predicate = read_builder.new_predicate_builder().equal("id", 9)
+ read_builder = read_builder.with_filter(predicate).with_limit(1)
+ result = read_builder.new_read().to_arrow(
+ read_builder.new_scan().plan().splits()
+ )
+ self.assertEqual(result.num_rows, 1)
+ self.assertEqual(result.column('id').to_pylist(), [9])
+ self.assertEqual(result.column('picture').to_pylist(), [b'payload-9'])
+
+ def
test_blob_view_raw_split_predicate_and_limit_resolves_filtered_row(self):
+ """Same hole on RawFileSplitRead: view-only prescan plus LIMIT.
+
+ Python schema validation still requires data-evolution for BLOB
+ tables, so the table is created that way and the read is copied
+ with data-evolution off to force the append/raw split path.
+ """
+ from unittest import mock
+
+ from pypaimon import Schema
+ from pypaimon.globalindex.indexed_split import IndexedSplit
+ from pypaimon.read.split_read import RawFileSplitRead
+ from pypaimon.read.table_read import TableRead
+ from pypaimon.table.row.blob import BlobViewStruct
+ from pypaimon.utils.range import Range
+
+ source_schema = pa.schema([
+ ('id', pa.int32()),
+ ('picture', pa.large_binary()),
+ ])
+ source = Schema.from_pyarrow_schema(
+ source_schema,
+ options={
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ }
+ )
+ self.catalog.create_table(
+ 'test_db.blob_view_raw_pred_limit_source', source, False)
+ source_table = self.catalog.get_table(
+ 'test_db.blob_view_raw_pred_limit_source')
+
+ num_rows = 10
+ payloads = [f'raw-payload-{i}'.encode() for i in range(num_rows)]
+ write_builder = source_table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ writer.write_arrow(pa.Table.from_pydict({
+ 'id': list(range(num_rows)),
+ 'picture': payloads,
+ }, schema=source_schema))
+ write_builder.new_commit().commit(writer.prepare_commit())
+ writer.close()
+
+ picture_field_id = next(
+ field.id for field in source_table.table_schema.fields
+ if field.name == 'picture'
+ )
+ view_values = [
+ BlobViewStruct(
+ 'test_db.blob_view_raw_pred_limit_source', picture_field_id, i
+ ).serialize()
+ for i in range(num_rows)
+ ]
+
+ target_schema = pa.schema([
+ ('id', pa.int32()),
+ ('picture', pa.large_binary()),
+ ])
+ target = Schema.from_pyarrow_schema(
+ target_schema,
+ options={
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ 'blob-view-field': 'picture',
+ }
+ )
+ self.catalog.create_table(
+ 'test_db.blob_view_raw_pred_limit_target', target, False)
+ target_table = self.catalog.get_table(
+ 'test_db.blob_view_raw_pred_limit_target')
+
+ target_write_builder = target_table.new_batch_write_builder()
+ target_writer = target_write_builder.new_write()
+ target_writer.write_arrow(pa.Table.from_pydict({
+ 'id': list(range(num_rows)),
+ 'picture': view_values,
+ }, schema=target_schema))
+ target_write_builder.new_commit().commit(
+ target_writer.prepare_commit())
+ target_writer.close()
+
+ raw_table = target_table.copy({'data-evolution.enabled': 'false'})
+ self.assertFalse(raw_table.options.data_evolution_enabled())
+
+ read_builder = raw_table.new_read_builder()
+ predicate = read_builder.new_predicate_builder().equal("id", 9)
+ read_builder = read_builder.with_filter(predicate).with_limit(1)
+ split_types = []
+ orig_build = TableRead._build_split_read
+
+ def capturing_build(self, *args, **kwargs):
+ split_read = orig_build(self, *args, **kwargs)
+ split_types.append(type(split_read))
+ return split_read
+
+ with mock.patch.object(TableRead, '_build_split_read',
capturing_build):
+ result = read_builder.new_read().to_arrow(
+ read_builder.new_scan().plan().splits()
+ )
+ self.assertIn(RawFileSplitRead, split_types)
+ self.assertEqual(result.num_rows, 1)
+ self.assertEqual(result.column('id').to_pylist(), [9])
+ self.assertEqual(
+ result.column('picture').to_pylist(), [b'raw-payload-9'])
+
+ # IndexedSplit selects the last row without a predicate. The view
+ # prescan must use the same row ranges before applying LIMIT.
+ with self.subTest(selection="indexed_split"):
+ indexed_read = raw_table.new_read_builder().with_limit(1)
+ indexed_splits = [
+ IndexedSplit(split, [Range(9, 9)])
+ for split in indexed_read.new_scan().plan().splits()
+ ]
+ result = indexed_read.new_read().to_arrow(indexed_splits)
+ self.assertEqual(result.num_rows, 1)
+ self.assertEqual(result.column('id').to_pylist(), [9])
+ self.assertEqual(
+ result.column('picture').to_pylist(), [b'raw-payload-9'])
+
class GetBlobTest(unittest.TestCase):
diff --git a/paimon-python/pypaimon/tests/blob_test.py
b/paimon-python/pypaimon/tests/blob_test.py
index 2a60e229af..736c7ddd97 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -1675,6 +1675,1198 @@ class BlobTest(unittest.TestCase):
[pa.array([video_bytes + b"x"], type=pa.large_binary())],
names=["payload"]))
+ def test_offset_row_get_blob_uses_table_file_io(self):
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ data = b"row table blob"
+ descriptor = BlobDescriptor("file-backed/row.bin", 0, len(data))
+ file_io = self._token_aware_file_io(data)
+ row = OffsetRow(
+ (descriptor.serialize(),), 0, 1,
+ file_io=file_io,
+ blob_field_indices=[0],
+ descriptor_field_indices=[0],
+ )
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+ self.assertEqual(file_io.opened_paths, [descriptor.uri])
+
+ file_io = self._token_aware_file_io(data)
+ row = OffsetRow(
+ (descriptor.serialize(),), 0, 1,
+ file_io=file_io,
+ blob_field_indices=[0],
+ )
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+ self.assertEqual(file_io.opened_paths, [descriptor.uri])
+
+ def test_blob_inline_convert_reader_uses_table_file_io(self):
+ from typing import Optional
+
+ from pyarrow import RecordBatch
+
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.blob_descriptor_convert_reader import
BlobInlineConvertReader
+ from pypaimon.read.reader.iface.record_batch_reader import
RecordBatchReader
+
+ data = b"convert table blob"
+ descriptor = BlobDescriptor("file-backed/convert.bin", 0, len(data))
+ file_io = self._token_aware_file_io(data)
+ batch = RecordBatch.from_arrays(
+ [pa.array([descriptor.serialize()], type=pa.large_binary())],
+ names=["payload"],
+ )
+
+ class _InnerReader(RecordBatchReader):
+ def __init__(self):
+ self.file_io = file_io
+ self._batch = batch
+ self._done = False
+
+ def read_arrow_batch(self) -> Optional[RecordBatch]:
+ if self._done:
+ return None
+ self._done = True
+ return self._batch
+
+ def close(self):
+ pass
+
+ class _CatalogEnvironment:
+ catalog_loader = None
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "false",
+ "blob-descriptor-field": "payload",
+ }))
+ catalog_environment = _CatalogEnvironment()
+
+ table = _Table()
+ table.file_io = file_io
+ reader = BlobInlineConvertReader(_InnerReader(), table)
+ result = reader.read_arrow_batch()
+ self.assertEqual(result.column("payload").to_pylist(), [data])
+ self.assertEqual(file_io.opened_paths, [descriptor.uri])
+ reader.close()
+
+ def test_offset_row_get_blob_video_frame_descriptor_bytes(self):
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ data = b"video-frame-payload"
+ descriptor = VideoFrameDescriptor(
+ "file-backed/video.mp4", 0, len(data), 2)
+ file_io = self._token_aware_file_io(data)
+ row = OffsetRow(
+ (descriptor.serialize(),), 0, 1,
+ file_io=file_io,
+ blob_field_indices=[0],
+ descriptor_field_indices=[0],
+ )
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_descriptor(), descriptor)
+ self.assertEqual(blob.to_data(), data)
+ self.assertEqual(file_io.opened_paths, [descriptor.uri])
+
+ def test_blob_inline_convert_reader_resolves_video_frame_descriptor(self):
+ from typing import Optional
+
+ from pyarrow import RecordBatch
+
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.blob_descriptor_convert_reader import (
+ BlobInlineConvertReader)
+ from pypaimon.read.reader.iface.record_batch_reader import (
+ RecordBatchReader)
+
+ data = b"convert video blob"
+ descriptor = VideoFrameDescriptor(
+ "file-backed/convert.mp4", 0, len(data), 2)
+ file_io = self._token_aware_file_io(data)
+ batch = RecordBatch.from_arrays(
+ [pa.array([descriptor.serialize()], type=pa.large_binary())],
+ names=["payload"],
+ )
+
+ class _InnerReader(RecordBatchReader):
+ def __init__(self):
+ self.file_io = file_io
+ self._batch = batch
+ self._done = False
+
+ def read_arrow_batch(self) -> Optional[RecordBatch]:
+ if self._done:
+ return None
+ self._done = True
+ return self._batch
+
+ def close(self):
+ pass
+
+ class _CatalogEnvironment:
+ catalog_loader = None
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "false",
+ "blob-descriptor-field": "payload",
+ }))
+ catalog_environment = _CatalogEnvironment()
+
+ table = _Table()
+ table.file_io = file_io
+ reader = BlobInlineConvertReader(_InnerReader(), table)
+ result = reader.read_arrow_batch()
+ self.assertEqual(result.column("payload").to_pylist(), [data])
+ self.assertEqual(file_io.opened_paths, [descriptor.uri])
+ reader.close()
+
+ def test_read_blobs_concurrent_preserves_blob_ref_readers(self):
+ from unittest.mock import MagicMock
+
+ from pypaimon.common.file_io import FileIO
+ from pypaimon.common.uri_reader import FileUriReader, UriReader
+
+ shared_uri = "s3://shared/blob"
+ descriptor = BlobDescriptor(shared_uri, 0, 4)
+ source_a = MagicMock()
+ source_b = MagicMock()
+ source_a.read_ranges_coalesced.return_value = [b"AAAA"]
+ source_b.read_ranges_coalesced.return_value = [b"BBBB"]
+
+ class MemoryUriReader(UriReader):
+ def __init__(self):
+ self.opened = []
+
+ def new_input_stream(self, uri):
+ self.opened.append(uri)
+ return io.BytesIO(b"HTTP")
+
+ http_uri = "https://example.com/blob"
+ http_reader = MemoryUriReader()
+
+ class _BlobRefSubclass(BlobRef):
+ def new_input_stream(self):
+ return io.BytesIO(b"SUBC")
+
+ subclass_reader = FileUriReader(MagicMock())
+ blobs = [
+ BlobRef(FileUriReader(source_a), descriptor),
+ BlobRef(FileUriReader(source_b), descriptor),
+ BlobRef(http_reader, BlobDescriptor(http_uri, 0, 4)),
+ _BlobRefSubclass(subclass_reader, descriptor),
+ ]
+ target_file_io = MagicMock()
+
+ result = FileIO.read_blobs_concurrent(target_file_io, blobs, 4)
+
+ self.assertEqual(result, [b"AAAA", b"BBBB", b"HTTP", b"SUBC"])
+ source_a.read_ranges_coalesced.assert_called_once_with(
+ [(shared_uri, 0, 4)], 4)
+ source_b.read_ranges_coalesced.assert_called_once_with(
+ [(shared_uri, 0, 4)], 4)
+ target_file_io.read_ranges_coalesced.assert_not_called()
+ subclass_reader.file_io.read_ranges_coalesced.assert_not_called()
+ self.assertEqual(http_reader.opened, [http_uri])
+
+ def test_deferred_blob_resolve_reader_uses_table_file_io(self):
+ from typing import Optional
+
+ from pypaimon.read.reader.deferred_blob_resolve_reader import (
+ DeferredBlobResolveReader)
+ from pypaimon.read.reader.iface.record_batch_reader import
RecordBatchReader
+
+ data = b"deferred table blob"
+ descriptor = BlobDescriptor("file-backed/blob.bin", 0, len(data))
+
+ class FailingFactory:
+ def create(self, uri):
+ raise AssertionError(
+ "dedicated blob resolve should use table FileIO")
+
+ class FileBackedIO:
+ def __init__(self):
+ self.uri_reader_factory = FailingFactory()
+ self.opened_paths = []
+
+ def new_input_stream(self, path):
+ self.opened_paths.append(path)
+ return io.BytesIO(data)
+
+ file_io = FileBackedIO()
+ batch = pa.RecordBatch.from_arrays(
+ [pa.array([descriptor.serialize()], type=pa.large_binary())],
+ names=["payload"],
+ )
+
+ class _InnerReader(RecordBatchReader):
+ def __init__(self):
+ self._done = False
+
+ def read_arrow_batch(self) -> Optional[pa.RecordBatch]:
+ if self._done:
+ return None
+ self._done = True
+ return batch
+
+ def close(self):
+ pass
+
+ reader = DeferredBlobResolveReader(_InnerReader(), file_io,
["payload"])
+ result = reader.read_arrow_batch()
+ self.assertEqual(result.column("payload").to_pylist(), [data])
+ self.assertEqual(file_io.opened_paths, [descriptor.uri])
+ reader.close()
+
+ def test_offset_row_get_blob_v1_descriptor_bytes(self):
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ data = b"row-level blob payload"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "blob.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(data)
+ uri = blob_path.encode('utf-8')
+ serialized_v1 = (
+ bytes([1])
+ + struct.pack('<I', len(uri))
+ + uri
+ + struct.pack('<q', 0)
+ + struct.pack('<q', len(data))
+ )
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+ row = OffsetRow(
+ (serialized_v1,), 0, 1, file_io=file_io,
+ blob_field_indices=[0], descriptor_field_indices=[0])
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+
+ def
test_descriptor_field_indices_for_table_includes_blob_view_fields(self):
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.field_indices import
descriptor_field_indices_for_table
+ from pypaimon.schema.data_types import AtomicType, DataField
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "true",
+ "blob-descriptor-field": "desc_col",
+ "blob-view-field": "view_col",
+ }))
+
+ fields = [
+ DataField(0, "desc_col", AtomicType("BYTES")),
+ DataField(1, "view_col", AtomicType("BYTES")),
+ DataField(2, "payload", AtomicType("BYTES")),
+ ]
+ self.assertEqual(descriptor_field_indices_for_table(_Table(), fields),
{0, 1})
+
+ def
test_descriptor_field_indices_include_descriptor_fields_without_blob_as_descriptor(self):
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.field_indices import
descriptor_field_indices_for_table
+ from pypaimon.schema.data_types import AtomicType, DataField
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "false",
+ "blob-descriptor-field": "desc_col",
+ "blob-view-field": "view_col",
+ }))
+
+ fields = [
+ DataField(0, "desc_col", AtomicType("BYTES")),
+ DataField(1, "view_col", AtomicType("BYTES")),
+ ]
+ self.assertEqual(descriptor_field_indices_for_table(_Table(), fields),
{0})
+
+ def
test_offset_row_get_blob_v1_descriptor_without_blob_as_descriptor(self):
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.field_indices import
descriptor_field_indices_for_table
+ from pypaimon.schema.data_types import AtomicType, DataField
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ data = b"inline descriptor payload"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "blob.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(data)
+ uri = blob_path.encode('utf-8')
+ serialized_v1 = (
+ bytes([1])
+ + struct.pack('<I', len(uri))
+ + uri
+ + struct.pack('<q', 0)
+ + struct.pack('<q', len(data))
+ )
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "false",
+ "blob-descriptor-field": "payload",
+ }))
+
+ fields = [DataField(0, "payload", AtomicType("BYTES"))]
+ descriptor_indices = descriptor_field_indices_for_table(_Table(),
fields)
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+ row = OffsetRow(
+ (serialized_v1,), 0, 1, file_io=file_io,
+ blob_field_indices=[0],
+ descriptor_field_indices=descriptor_indices)
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+
+ def test_offset_row_get_blob_v1_descriptor_with_trailing_padding(self):
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ data = b"row-level blob payload"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "blob.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(data)
+ uri = blob_path.encode('utf-8')
+ serialized_v1 = (
+ bytes([1])
+ + struct.pack('<I', len(uri))
+ + uri
+ + struct.pack('<q', 0)
+ + struct.pack('<q', len(data))
+ + b"padding"
+ )
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+ row = OffsetRow(
+ (serialized_v1,), 0, 1, file_io=file_io,
+ blob_field_indices=[0], descriptor_field_indices=[0])
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+
+ def test_offset_row_get_blob_materialized_descriptor_payload(self):
+ from pypaimon.table.row.blob import BlobData
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ payload = b"already materialized payload"
+ row = OffsetRow(
+ (payload,), 0, 1,
+ blob_field_indices=[0],
+ descriptor_field_indices=set(),
+ )
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobData)
+ self.assertEqual(blob.to_data(), payload)
+
+ def
test_offset_row_get_blob_descriptor_field_rejects_truncated_bytes(self):
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ truncated_v1 = bytes([1]) + struct.pack('<I', 5) + b"hel"
+ row = OffsetRow(
+ (truncated_v1,), 0, 1,
+ blob_field_indices=[0],
+ descriptor_field_indices=[0],
+ )
+ with self.assertRaises(ValueError):
+ row.get_blob(0)
+
+ def
test_blob_inline_convert_reader_clears_descriptor_indices_after_materialize(self):
+ from typing import Optional
+
+ from pyarrow import RecordBatch
+
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.blob_descriptor_convert_reader import
BlobInlineConvertReader
+ from pypaimon.read.reader.iface.record_batch_reader import
RecordBatchReader
+ from pypaimon.table.row.blob import BlobData
+
+ v1_shaped_inline = (
+ bytes([1])
+ + struct.pack('<I', 5)
+ + b"hello"
+ + struct.pack('<q', 0)
+ + struct.pack('<q', 5)
+ )
+
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "blob.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(v1_shaped_inline)
+ uri = blob_path.encode('utf-8')
+ serialized_v1 = (
+ bytes([1])
+ + struct.pack('<I', len(uri))
+ + uri
+ + struct.pack('<q', 0)
+ + struct.pack('<q', len(v1_shaped_inline))
+ )
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+ batch = RecordBatch.from_arrays(
+ [pa.array([serialized_v1], type=pa.large_binary())],
+ names=["payload"],
+ )
+
+ class _InnerReader(RecordBatchReader):
+ def __init__(self):
+ self.file_io = file_io
+ self.blob_field_indices = {0}
+ self.descriptor_field_indices = {0}
+ self._batch = batch
+ self._done = False
+
+ def read_arrow_batch(self) -> Optional[RecordBatch]:
+ if self._done:
+ return None
+ self._done = True
+ return self._batch
+
+ def close(self):
+ pass
+
+ class _CatalogEnvironment:
+ catalog_loader = None
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "false",
+ "blob-descriptor-field": "payload",
+ }))
+ catalog_environment = _CatalogEnvironment()
+
+ table = _Table()
+ table.file_io = file_io
+ inner = _InnerReader()
+ reader = BlobInlineConvertReader(inner, table)
+ self.assertEqual(reader.descriptor_field_indices, set())
+
+ row_iter = reader.read_batch()
+ self.assertIsNotNone(row_iter)
+ row = row_iter.next()
+ self.assertIsNotNone(row)
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobData)
+ self.assertEqual(blob.to_data(), v1_shaped_inline)
+ reader.close()
+
+ def test_wrap_record_reader_propagates_blob_metadata_for_get_blob(self):
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.blob_view_read_support import (
+ wrap_record_reader_with_blob_inline_convert)
+ from pypaimon.read.reader.iface.record_batch_reader import
EmptyRecordBatchReader
+ from pypaimon.read.reader.iface.record_iterator import RecordIterator
+ from pypaimon.read.reader.iface.record_reader import RecordReader
+ from pypaimon.schema.data_types import AtomicType, DataField
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ data = b"merge-path blob payload"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "blob.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(data)
+ serialized = BlobDescriptor(blob_path, 0, len(data)).serialize()
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+
+ class _OnceIterator(RecordIterator):
+ def __init__(self, row):
+ self._row = row
+ self._done = False
+
+ def next(self):
+ if self._done:
+ return None
+ self._done = True
+ return self._row
+
+ class _OnceReader(RecordReader):
+ def __init__(self, row):
+ self._row = row
+ self._done = False
+
+ def read_batch(self):
+ if self._done:
+ return None
+ self._done = True
+ return _OnceIterator(self._row)
+
+ def close(self):
+ pass
+
+ class _CatalogEnvironment:
+ catalog_loader = None
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "true",
+ "blob-descriptor-field": "picture",
+ }))
+ catalog_environment = _CatalogEnvironment()
+
+ table = _Table()
+ table.file_io = file_io
+
+ class _SplitRead:
+ def __init__(self):
+ self.table = table
+ self._blob_parallelism = 1
+
+ def _create_blob_view_prescan_reader(self, names):
+ return EmptyRecordBatchReader()
+
+ fields = [DataField(0, "picture", AtomicType("BLOB"))]
+ inner = _OnceReader(OffsetRow((serialized,), 0, 1))
+ wrapped = wrap_record_reader_with_blob_inline_convert(
+ inner, _SplitRead(), fields)
+ row_iter = wrapped.read_batch()
+ self.assertIsNotNone(row_iter)
+ row = row_iter.next()
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+ wrapped.close()
+
+ def test_wrap_record_reader_preserves_all_row_kinds(self):
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.blob_view_read_support import (
+ wrap_record_reader_with_blob_inline_convert)
+ from pypaimon.read.reader.iface.record_batch_reader import
EmptyRecordBatchReader
+ from pypaimon.read.reader.iface.record_iterator import RecordIterator
+ from pypaimon.read.reader.iface.record_reader import RecordReader
+ from pypaimon.schema.data_types import AtomicType, DataField
+ from pypaimon.table.row.offset_row import OffsetRow
+ from pypaimon.table.row.row_kind import RowKind
+
+ data = b"row-kind blob payload"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "blob.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(data)
+ serialized = BlobDescriptor(blob_path, 0, len(data)).serialize()
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+
+ class _KindIterator(RecordIterator):
+ def __init__(self, rows):
+ self._rows = list(rows)
+
+ def next(self):
+ if not self._rows:
+ return None
+ return self._rows.pop(0)
+
+ class _KindReader(RecordReader):
+ def __init__(self, rows):
+ self._rows = rows
+ self._done = False
+
+ def read_batch(self):
+ if self._done:
+ return None
+ self._done = True
+ return _KindIterator(self._rows)
+
+ def close(self):
+ pass
+
+ class _CatalogEnvironment:
+ catalog_loader = None
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "true",
+ "blob-descriptor-field": "picture",
+ }))
+ catalog_environment = _CatalogEnvironment()
+
+ table = _Table()
+ table.file_io = file_io
+
+ class _SplitRead:
+ def __init__(self):
+ self.table = table
+ self._blob_parallelism = 1
+
+ def _create_blob_view_prescan_reader(self, names):
+ return EmptyRecordBatchReader()
+
+ kinds = (
+ RowKind.INSERT, RowKind.UPDATE_BEFORE,
+ RowKind.UPDATE_AFTER, RowKind.DELETE)
+ rows = []
+ for kind in kinds:
+ row = OffsetRow((serialized,), 0, 1)
+ row.set_row_kind_byte(kind.value)
+ rows.append(row)
+ wrapped = wrap_record_reader_with_blob_inline_convert(
+ _KindReader(rows), _SplitRead(),
+ [DataField(0, "picture", AtomicType("BLOB"))])
+ out = []
+ batch = wrapped.read_batch()
+ while batch is not None:
+ row = batch.next()
+ while row is not None:
+ out.append(row.get_row_kind())
+ row = batch.next()
+ batch = wrapped.read_batch()
+ wrapped.close()
+ self.assertEqual(list(kinds), out)
+
+ def test_limit_before_wrap_materializes_only_limited_descriptors(self):
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.blob_view_read_support import (
+ wrap_record_reader_with_blob_inline_convert)
+ from pypaimon.read.reader.iface.record_batch_reader import
EmptyRecordBatchReader
+ from pypaimon.read.reader.iface.record_iterator import RecordIterator
+ from pypaimon.read.reader.iface.record_reader import RecordReader
+ from pypaimon.read.reader.limited_record_reader import
LimitedRecordReader
+ from pypaimon.schema.data_types import AtomicType, DataField
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ payloads = [b"first-blob-payload", b"second-blob-payload"]
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ serialized = []
+ for index, payload in enumerate(payloads):
+ blob_path = os.path.join(tmp_dir, "blob-%d.bin" % index)
+ with open(blob_path, 'wb') as f:
+ f.write(payload)
+ serialized.append(
+ BlobDescriptor(blob_path, 0, len(payload)).serialize())
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+ opened = []
+ original_open = file_io.new_input_stream
+
+ def counting_open(path):
+ opened.append(path)
+ return original_open(path)
+
+ file_io.new_input_stream = counting_open
+
+ class _Iter(RecordIterator):
+ def __init__(self, rows):
+ self._rows = list(rows)
+
+ def next(self):
+ if not self._rows:
+ return None
+ return self._rows.pop(0)
+
+ class _Reader(RecordReader):
+ def __init__(self, rows):
+ self._rows = rows
+ self._done = False
+
+ def read_batch(self):
+ if self._done:
+ return None
+ self._done = True
+ return _Iter(self._rows)
+
+ def close(self):
+ pass
+
+ class _CatalogEnvironment:
+ catalog_loader = None
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "false",
+ "blob-descriptor-field": "picture",
+ }))
+ catalog_environment = _CatalogEnvironment()
+
+ table = _Table()
+ table.file_io = file_io
+
+ class _SplitRead:
+ def __init__(self):
+ self.table = table
+ self._blob_parallelism = 1
+
+ def _create_blob_view_prescan_reader(self, names):
+ return EmptyRecordBatchReader()
+
+ rows = [OffsetRow((value,), 0, 1) for value in serialized]
+ limited = LimitedRecordReader(_Reader(rows), 1)
+ wrapped = wrap_record_reader_with_blob_inline_convert(
+ limited, _SplitRead(),
+ [DataField(0, "picture", AtomicType("BLOB"))])
+ batch = wrapped.read_batch()
+ row = batch.next()
+ self.assertEqual(row.get_blob(0).to_data(), payloads[0])
+ self.assertIsNone(batch.next())
+ self.assertIsNone(wrapped.read_batch())
+ wrapped.close()
+ self.assertEqual(1, len(opened))
+
+ def test_needs_blob_inline_convert_when_blob_as_descriptor(self):
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.blob_view_read_support import (
+ needs_blob_inline_convert)
+
+ class _Table:
+ def __init__(self, options):
+ self.options = CoreOptions(Options(options))
+
+ self.assertTrue(needs_blob_inline_convert(_Table({
+ "blob-as-descriptor": "true",
+ "blob-descriptor-field": "picture",
+ })))
+ self.assertTrue(needs_blob_inline_convert(_Table({
+ "blob-as-descriptor": "false",
+ "blob-descriptor-field": "picture",
+ })))
+ self.assertTrue(needs_blob_inline_convert(_Table({
+ "blob-as-descriptor": "true",
+ "blob-view-field": "picture",
+ })))
+ self.assertFalse(needs_blob_inline_convert(_Table({
+ "blob-as-descriptor": "true",
+ })))
+ self.assertFalse(needs_blob_inline_convert(_Table({
+ "blob.stored-descriptor-fields": "picture",
+ })))
+
+ def test_limited_record_reader_keeps_cleared_descriptor_indices(self):
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.auth_masking_reader import (
+ BatchToRecordReaderAdapter, RecordReaderToBatchAdapter)
+ from pypaimon.read.reader.blob_view_read_support import (
+ wrap_record_reader_with_blob_inline_convert)
+ from pypaimon.read.reader.iface.record_batch_reader import
EmptyRecordBatchReader
+ from pypaimon.read.reader.iface.record_iterator import RecordIterator
+ from pypaimon.read.reader.iface.record_reader import RecordReader
+ from pypaimon.read.reader.limited_record_reader import
LimitedRecordReader
+ from pypaimon.schema.data_types import AtomicType, DataField,
PyarrowFieldParser
+ from pypaimon.table.row.blob import BlobData
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ data = b"materialized through limit wrapper"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "blob.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(data)
+ serialized = BlobDescriptor(blob_path, 0, len(data)).serialize()
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+
+ class _OnceIterator(RecordIterator):
+ def __init__(self, row):
+ self._row = row
+ self._done = False
+
+ def next(self):
+ if self._done:
+ return None
+ self._done = True
+ return self._row
+
+ class _OnceReader(RecordReader):
+ def __init__(self, row):
+ self._row = row
+ self._done = False
+
+ def read_batch(self):
+ if self._done:
+ return None
+ self._done = True
+ return _OnceIterator(self._row)
+
+ def close(self):
+ pass
+
+ class _CatalogEnvironment:
+ catalog_loader = None
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "false",
+ "blob-descriptor-field": "picture",
+ }))
+ catalog_environment = _CatalogEnvironment()
+
+ table = _Table()
+ table.file_io = file_io
+
+ class _SplitRead:
+ def __init__(self):
+ self.table = table
+ self._blob_parallelism = 1
+
+ def _create_blob_view_prescan_reader(self, names):
+ return EmptyRecordBatchReader()
+
+ fields = [DataField(0, "picture", AtomicType("BLOB"))]
+ wrapped = wrap_record_reader_with_blob_inline_convert(
+ _OnceReader(OffsetRow((serialized,), 0, 1)), _SplitRead(),
fields)
+ limited = LimitedRecordReader(wrapped, 10)
+ self.assertEqual(limited.descriptor_field_indices, set())
+
+ schema = PyarrowFieldParser.from_paimon_schema(fields)
+ batch_reader = RecordReaderToBatchAdapter(limited, schema)
+ if getattr(batch_reader, 'blob_field_indices', None) is None:
+ from pypaimon.read.reader.field_indices import (
+ blob_field_indices, descriptor_field_indices_for_table,
+ vector_field_indices)
+ batch_reader.file_io = file_io
+ batch_reader.blob_field_indices = blob_field_indices(fields)
+ batch_reader.descriptor_field_indices = (
+ descriptor_field_indices_for_table(table, fields))
+ batch_reader.vector_field_indices =
vector_field_indices(fields)
+ reader = BatchToRecordReaderAdapter(batch_reader)
+ blob = reader.read_batch().next().get_blob(0)
+ self.assertIsInstance(blob, BlobData)
+ self.assertEqual(blob.to_data(), data)
+ reader.close()
+
+ def test_batch_to_record_reader_roundtrip_preserves_get_blob(self):
+ from pypaimon.read.reader.auth_masking_reader import (
+ BatchToRecordReaderAdapter, RecordReaderToBatchAdapter)
+ from pypaimon.read.reader.iface.record_iterator import RecordIterator
+ from pypaimon.read.reader.iface.record_reader import RecordReader
+ from pypaimon.schema.data_types import AtomicType, DataField,
PyarrowFieldParser
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ data = b"roundtrip blob payload"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "blob.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(data)
+ serialized = BlobDescriptor(blob_path, 0, len(data)).serialize()
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+
+ class _OnceIterator(RecordIterator):
+ def __init__(self, row):
+ self._row = row
+ self._done = False
+
+ def next(self):
+ if self._done:
+ return None
+ self._done = True
+ return self._row
+
+ class _OnceReader(RecordReader):
+ def __init__(self, row):
+ self._row = row
+ self._done = False
+
+ def read_batch(self):
+ if self._done:
+ return None
+ self._done = True
+ return _OnceIterator(self._row)
+
+ def close(self):
+ pass
+
+ fields = [DataField(0, "picture", AtomicType("BLOB"))]
+ schema = PyarrowFieldParser.from_paimon_schema(fields)
+ batch_reader = RecordReaderToBatchAdapter(
+ _OnceReader(OffsetRow((serialized,), 0, 1)), schema)
+ batch_reader.file_io = file_io
+ batch_reader.blob_field_indices = {0}
+ batch_reader.descriptor_field_indices = {0}
+ first = BatchToRecordReaderAdapter(batch_reader)
+ second_batch = RecordReaderToBatchAdapter(first, schema)
+ wrapped = BatchToRecordReaderAdapter(second_batch)
+ row = wrapped.read_batch().next()
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+ wrapped.close()
+
+ def test_blob_view_prescan_limit_skips_when_predicate_present(self):
+ from pypaimon.read.split_read import RawFileSplitRead
+
+ obj = RawFileSplitRead.__new__(RawFileSplitRead)
+ obj.limit = 1
+ obj.predicate = None
+ obj._post_merge_filter = None
+ self.assertEqual(1, obj._blob_view_prescan_limit())
+
+ obj.predicate = object()
+ self.assertIsNone(obj._blob_view_prescan_limit())
+
+ obj.predicate = None
+ obj._post_merge_filter = object()
+ self.assertIsNone(obj._blob_view_prescan_limit())
+
+ def test_merge_blob_view_prescan_empty_projection_uses_batch_reader(self):
+ from pypaimon.read.reader.iface.record_batch_reader import
EmptyRecordBatchReader
+ from pypaimon.read.split_read import MergeFileSplitRead
+ from pypaimon.schema.data_types import AtomicType, DataField
+
+ obj = MergeFileSplitRead.__new__(MergeFileSplitRead)
+ obj.read_fields = [
+ DataField(0, "_KEY_id", AtomicType("INT")),
+ DataField(1, "id", AtomicType("INT")),
+ ]
+ obj.value_arity = 1
+ reader = MergeFileSplitRead._create_blob_view_prescan_reader(
+ obj, {"picture"})
+ self.assertIsInstance(reader, EmptyRecordBatchReader)
+ self.assertIsNone(reader.read_arrow_batch())
+
+ def test_merge_blob_view_prescan_keeps_sequence_field(self):
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.split_read import MergeFileSplitRead
+ from pypaimon.schema.data_types import AtomicType, DataField
+
+ pic = DataField(1, "pic", AtomicType("BLOB"))
+ ts = DataField(2, "ts", AtomicType("INT"))
+ obj = MergeFileSplitRead.__new__(MergeFileSplitRead)
+ obj.read_fields = [
+ DataField(0, "_KEY_id", AtomicType("INT")),
+ DataField(0, "id", AtomicType("INT")),
+ ts,
+ pic,
+ ]
+ obj.value_arity = 3
+
+ class _Table:
+ options = CoreOptions(Options({"sequence.field": "ts"}))
+ fields = [
+ DataField(0, "id", AtomicType("INT")),
+ ts,
+ pic,
+ ]
+
+ obj.table = _Table()
+ names = [
+ f.name for f in
+ MergeFileSplitRead._blob_view_prescan_read_type(obj, {"pic"})
+ ]
+ self.assertEqual(names, ["pic", "ts"])
+
+ def
test_refresh_blob_view_lookup_does_not_clobber_descriptor_indices(self):
+ from pypaimon.read.reader.auth_masking_reader import (
+ RecordReaderToBatchAdapter)
+ from pypaimon.read.reader.iface.record_iterator import RecordIterator
+ from pypaimon.read.reader.iface.record_reader import RecordReader
+ from pypaimon.read.reader.limited_record_reader import
LimitedRecordReader
+ from pypaimon.read.reader.outer_projection_record_reader import (
+ OuterProjectionRecordReader)
+ from pypaimon.schema.data_types import AtomicType, DataField,
PyarrowFieldParser
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ class _Iter(RecordIterator):
+ def __init__(self, row):
+ self._row = row
+ self._done = False
+
+ def next(self):
+ if self._done:
+ return None
+ self._done = True
+ return self._row
+
+ class _Reader(RecordReader):
+ def __init__(self, row):
+ self._row = row
+ self._done = False
+
+ def read_batch(self):
+ if self._done:
+ return None
+ self._done = True
+ return _Iter(self._row)
+
+ def close(self):
+ pass
+
+ fields = [DataField(0, "picture", AtomicType("BLOB"))]
+ schema = PyarrowFieldParser.from_paimon_schema(fields)
+ limited = LimitedRecordReader(_Reader(OffsetRow((b"x",), 0, 1)), 1)
+ adapter = RecordReaderToBatchAdapter(limited, schema,
include_row_kind=True)
+ adapter.descriptor_field_indices = {0}
+ self.assertIsNotNone(adapter.read_arrow_batch())
+ self.assertEqual(adapter.descriptor_field_indices, {0})
+
+ inner_row = OffsetRow((0, 1, 2, 3, 4, b"desc"), 0, 6)
+ projected = OuterProjectionRecordReader(
+ _Reader(inner_row),
+ ["a", "b", "c", "d", "e", "payload"],
+ [["payload"]],
+ descriptor_field_indices={5},
+ )
+ self.assertEqual(projected.descriptor_field_indices, {0})
+ self.assertIsNotNone(projected.read_batch())
+ self.assertEqual(projected.descriptor_field_indices, {0})
+
+ def
test_blob_inline_convert_prescan_empty_projection_reads_main_batch(self):
+ from typing import Optional
+
+ from pyarrow import RecordBatch
+
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.blob_descriptor_convert_reader import (
+ BlobInlineConvertReader)
+ from pypaimon.read.reader.iface.record_batch_reader import (
+ EmptyRecordBatchReader, RecordBatchReader)
+
+ batch = RecordBatch.from_arrays(
+ [pa.array([1], type=pa.int32())], names=["id"])
+
+ class _InnerReader(RecordBatchReader):
+ def __init__(self):
+ self._done = False
+
+ def read_arrow_batch(self) -> Optional[RecordBatch]:
+ if self._done:
+ return None
+ self._done = True
+ return batch
+
+ def close(self):
+ pass
+
+ class _CatalogLoader:
+ pass
+
+ class _CatalogEnvironment:
+ catalog_loader = _CatalogLoader()
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "true",
+ "blob-view-field": "picture",
+ }))
+ catalog_environment = _CatalogEnvironment()
+
+ reader = BlobInlineConvertReader(
+ _InnerReader(),
+ _Table(),
+ prescan_reader_factory=lambda names: EmptyRecordBatchReader(),
+ )
+ result = reader.read_arrow_batch()
+ self.assertIsNotNone(result)
+ self.assertEqual(result.column("id").to_pylist(), [1])
+ reader.close()
+
+ def test_blob_view_lookup_http_descriptor_uses_http_uri_reader(self):
+ from unittest.mock import MagicMock
+
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.common.uri_reader import HttpUriReader, UriReaderFactory
+ from pypaimon.utils.blob_view_lookup import BlobViewLookup
+
+ class TokenFileIO:
+ def new_input_stream(self, path):
+ raise AssertionError(
+ "HTTP descriptors must not use the table FileIO")
+
+ table_key = "db.source"
+ view_struct = BlobViewStruct(Identifier.from_string(table_key), 1, 0)
+ http_uri = "https://example.com/blob.bin"
+ descriptor = BlobDescriptor(http_uri, 0, 4)
+ lookup = BlobViewLookup(MagicMock())
+ lookup._uri_reader_factory_cache[table_key] = (
+ UriReaderFactory.from_file_io(TokenFileIO()))
+ lookup._store_chunk_results({view_struct: descriptor}, set())
+ reader = lookup.resolve_uri_reader(view_struct)
+ self.assertIsInstance(reader, HttpUriReader)
+
+ def test_blob_view_http_descriptor_materializes_serial_and_parallel(self):
+ from types import SimpleNamespace
+ from unittest.mock import MagicMock
+
+ from pypaimon.common.file_io import FileIO
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.common.uri_reader import UriReader
+ from pypaimon.read.reader.blob_descriptor_convert_reader import (
+ BlobInlineConvertReader)
+
+ view_struct = BlobViewStruct(Identifier.from_string("db.source"), 1, 0)
+ descriptor = BlobDescriptor("https://example.com/blob", 0, 4)
+ batch = pa.RecordBatch.from_arrays(
+ [pa.array([view_struct.serialize()], type=pa.large_binary())],
+ names=["picture"],
+ )
+
+ class MemoryUriReader(UriReader):
+ def __init__(self):
+ self.opened = []
+
+ def new_input_stream(self, uri):
+ self.opened.append(uri)
+ return io.BytesIO(b"DATA")
+
+ class TargetFileIO:
+ def read_blobs_concurrent(self, blobs, parallelism):
+ return FileIO.read_blobs_concurrent(self, blobs, parallelism)
+
+ def read_ranges_coalesced(self, ranges, parallelism):
+ raise AssertionError("HTTP blobs must not use the target
FileIO")
+
+ for parallelism in (1, 4):
+ with self.subTest(parallelism=parallelism):
+ uri_reader = MemoryUriReader()
+ lookup = MagicMock()
+ lookup.resolve_to_null.return_value = False
+ lookup.resolve_blob.return_value = BlobRef(uri_reader,
descriptor)
+ reader =
BlobInlineConvertReader.__new__(BlobInlineConvertReader)
+ reader._view_fields = {"picture"}
+ reader._descriptor_fields = set()
+ reader._blob_as_descriptor = False
+ reader._blob_parallelism = parallelism
+ reader._table = SimpleNamespace(file_io=TargetFileIO())
+
+ descriptor_batch, view_blobs = reader._resolve_view_fields(
+ batch, lookup)
+ result = reader._resolve_descriptor_fields(
+ descriptor_batch, view_blobs)
+
+ self.assertEqual(result.column("picture").to_pylist(),
[b"DATA"])
+ self.assertEqual(uri_reader.opened, [descriptor.uri])
+ lookup.resolve_blob.assert_called_once_with(view_struct)
+
+ def test_internal_row_wrapper_iterator_passes_blob_view_lookup(self):
+ from unittest.mock import MagicMock
+
+ from pypaimon.read.reader.iface.record_batch_reader import
InternalRowWrapperIterator
+ from pypaimon.table.row.blob import BlobViewStruct
+ from pypaimon.common.identifier import Identifier
+
+ view_struct = BlobViewStruct(Identifier.from_string("db.source"), 1,
42)
+ lookup = MagicMock()
+ lookup.resolve_to_null.return_value = True
+ iterator = InternalRowWrapperIterator(
+ iter([(view_struct.serialize(),)]),
+ 1,
+ blob_field_indices=[0],
+ blob_view_lookup=lookup,
+ )
+ row = iterator.next()
+ self.assertIsNone(row.get_blob(0))
+ lookup.resolve_to_null.assert_called_once()
+
+ def _token_aware_file_io(self, data):
+ class FailingFactory:
+ def create(self, uri):
+ raise AssertionError(
+ "descriptor reads must reuse the table FileIO, not catalog
factory")
+
+ class TokenFileIO:
+ def __init__(self):
+ self.uri_reader_factory = FailingFactory()
+ self.opened_paths = []
+
+ def new_input_stream(self, path):
+ self.opened_paths.append(path)
+ return io.BytesIO(data)
+
+ return TokenFileIO()
+
class BlobEndToEndTest(unittest.TestCase):
"""End-to-end tests for blob functionality with schema definition, file
writing, and reading."""
diff --git a/paimon-python/pypaimon/tests/resolving_file_io_test.py
b/paimon-python/pypaimon/tests/resolving_file_io_test.py
index f73948a0f9..b6e045d965 100644
--- a/paimon-python/pypaimon/tests/resolving_file_io_test.py
+++ b/paimon-python/pypaimon/tests/resolving_file_io_test.py
@@ -106,6 +106,40 @@ class ResolvingFileIOTest(unittest.TestCase):
resolving = ResolvingFileIO(opts)
self.assertFalse(resolving.is_object_store())
+ def test_from_file_io_creates_http_reader(self):
+ from pypaimon.common.uri_reader import UriReaderFactory
+
+ resolving = ResolvingFileIO(Options({}))
+ try:
+ factory = UriReaderFactory.from_file_io(resolving)
+ reader = factory.create("https://example.com/blob.bin")
+ self.assertEqual(type(reader).__name__, "HttpUriReader")
+ finally:
+ resolving.close()
+
+ def test_from_file_io_reuses_self_for_non_http(self):
+ import io
+
+ from pypaimon.common.uri_reader import FileUriReader, UriReaderFactory
+
+ resolving = ResolvingFileIO(Options({}))
+ opened = []
+
+ def tracking(path):
+ opened.append(path)
+ return io.BytesIO(b"ok")
+
+ resolving.new_input_stream = tracking
+ try:
+ reader = UriReaderFactory.from_file_io(resolving).create(
+ "file:///tmp/blob.bin")
+ self.assertIsInstance(reader, FileUriReader)
+ self.assertEqual(
+ reader.new_input_stream("file:///tmp/blob.bin").read(), b"ok")
+ self.assertEqual(opened, ["file:///tmp/blob.bin"])
+ finally:
+ resolving.close()
+
class ResolvingFileIOReadWriteTest(unittest.TestCase):
"""End-to-end read/write tests using ResolvingFileIO with local
filesystem."""
diff --git a/paimon-python/pypaimon/tests/uri_reader_factory_test.py
b/paimon-python/pypaimon/tests/uri_reader_factory_test.py
index 5973cdd618..4e2948c362 100644
--- a/paimon-python/pypaimon/tests/uri_reader_factory_test.py
+++ b/paimon-python/pypaimon/tests/uri_reader_factory_test.py
@@ -18,6 +18,7 @@
import os
import tempfile
import unittest
+import io
from pypaimon.common.file_io import FileIO
from pypaimon.common.uri_reader import UriReaderFactory, HttpUriReader,
FileUriReader, UriReader
@@ -120,6 +121,33 @@ class UriReaderFactoryTest(unittest.TestCase):
self.factory.create("http://example.com/another_file.txt")
self.assertEqual(self.factory.get_cache_size(), initial_size + 3)
+ def test_clear_cache_releases_owned_file_ios(self):
+ self.factory.create(f"file://{self.temp_file}")
+ self.assertEqual(len(self.factory._owned_file_ios), 1)
+ self.factory.clear_cache()
+ self.assertEqual(self.factory.get_cache_size(), 0)
+ self.assertEqual(self.factory._owned_file_ios, [])
+
+ def test_lru_eviction_keeps_owned_file_ios_until_close(self):
+ from cachetools import LRUCache
+
+ small_factory = UriReaderFactory({})
+ small_factory._readers = LRUCache(1)
+ small_factory.create(f"file://{self.temp_file}")
+ self.assertEqual(len(small_factory._owned_file_ios), 1)
+ small_factory.create("http://example.com/other")
+ self.assertEqual(len(small_factory._owned_file_ios), 1)
+ small_factory.clear_cache()
+ self.assertEqual(len(small_factory._owned_file_ios), 0)
+
+ def test_pickle_resets_reader_cache(self):
+ import pickle
+
+ self.factory.create(f"file://{self.temp_file}")
+ restored = pickle.loads(pickle.dumps(self.factory))
+ self.assertEqual(restored.get_cache_size(), 0)
+ self.assertEqual(restored._owned_file_ios, [])
+
def test_uri_reader_functionality(self):
"""Test that created URI readers actually work."""
# Test file URI reader
@@ -223,6 +251,69 @@ class UriReaderFactoryTest(unittest.TestCase):
path = UriReader.get_file_path(self.temp_file)
self.assertEqual(str(path), self.temp_file)
+ def test_from_file_io_reuses_provided_file_io_for_non_http(self):
+ data = b"token-scoped blob"
+
+ class TokenFileIO:
+ def __init__(self):
+ self.opened_paths = []
+
+ def new_input_stream(self, path):
+ self.opened_paths.append(path)
+ return io.BytesIO(data)
+
+ file_io = TokenFileIO()
+ factory = UriReaderFactory.from_file_io(file_io)
+ self.assertIs(factory, UriReaderFactory.from_file_io(file_io))
+ self.assertIsInstance(factory.create("https://example.com/blob.bin"),
HttpUriReader)
+ reader = factory.create("file-backed/blob.bin")
+ self.assertIsInstance(reader, FileUriReader)
+
self.assertEqual(reader.new_input_stream("file-backed/blob.bin").read(), data)
+ self.assertEqual(file_io.opened_paths, ["file-backed/blob.bin"])
+
+ def test_from_file_io_cache_does_not_pin_file_io(self):
+ import gc
+ import weakref
+
+ class TokenFileIO:
+ def new_input_stream(self, path):
+ return io.BytesIO(b"x")
+
+ file_io = TokenFileIO()
+ factory = UriReaderFactory.from_file_io(file_io)
+ factory.create("file:///tmp/blob.bin")
+ factory.create("https://example.com/blob.bin")
+ self.assertIs(factory, UriReaderFactory.from_file_io(file_io))
+ self.assertEqual(factory.get_cache_size(), 1)
+ file_io_ref = weakref.ref(file_io)
+ del file_io
+ gc.collect()
+ self.assertIsNone(file_io_ref())
+
+ def test_from_file_io_factory_is_pickleable(self):
+ import pickle
+ import weakref
+
+ file_io = FileIO.get(self.temp_dir)
+ factory = UriReaderFactory.from_file_io(file_io)
+ factory.create(f"file://{self.temp_file}")
+ factory.create("https://example.com/blob.bin")
+ # The factory only weakly refs FileIO, so FileIO must be in the pickle
+ # graph (as it is on a table / ResolvingFileIO).
+ restored_file_io, restored = pickle.loads(pickle.dumps((file_io,
factory)))
+ self.assertEqual(restored.get_cache_size(), 0)
+ self.assertIsInstance(restored._provided_file_io, weakref.ref)
+ self.assertIs(restored._provided_file_io(), restored_file_io)
+ reader = restored.create(f"file://{self.temp_file}")
+ self.assertIsInstance(reader, FileUriReader)
+ stream = reader.new_input_stream(self.temp_file)
+ try:
+ self.assertEqual(stream.read().decode('utf-8'), "test content")
+ finally:
+ stream.close()
+ self.assertIsInstance(
+ restored.create("https://example.com/blob.bin"), HttpUriReader)
+
if __name__ == '__main__':
unittest.main()
diff --git a/paimon-python/pypaimon/tests/vector_table_test.py
b/paimon-python/pypaimon/tests/vector_table_test.py
index deb7f82d6c..96725305fc 100644
--- a/paimon-python/pypaimon/tests/vector_table_test.py
+++ b/paimon-python/pypaimon/tests/vector_table_test.py
@@ -24,6 +24,7 @@ import pyarrow as pa
from pypaimon import CatalogFactory, Schema
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.table.row.offset_row import OffsetRow
from pypaimon.table.row.vector import Vector
@@ -70,6 +71,12 @@ class VectorClassTest(unittest.TestCase):
self.assertEqual(len(v), 0)
self.assertEqual(v.to_list(), [])
+ def test_offset_row_legacy_positional_vector_indices(self):
+ row = OffsetRow(([1.0, 2.0],), 0, 1, None, None, {0})
+
+ self.assertEqual(row.get_vector(0).to_list(), [1.0, 2.0])
+ self.assertEqual(row._descriptor_field_indices, frozenset())
+
class VectorFileDetectionTest(unittest.TestCase):
diff --git a/paimon-python/pypaimon/utils/blob_view_lookup.py
b/paimon-python/pypaimon/utils/blob_view_lookup.py
index 03867b9885..37d6df5c7e 100644
--- a/paimon-python/pypaimon/utils/blob_view_lookup.py
+++ b/paimon-python/pypaimon/utils/blob_view_lookup.py
@@ -16,10 +16,10 @@
# under the License.
from concurrent.futures import ThreadPoolExecutor, as_completed
-from typing import Dict, List, Tuple, Set
+from typing import Dict, List, Set, Tuple
from pypaimon.common.identifier import Identifier
-from pypaimon.common.uri_reader import FileUriReader, UriReader
+from pypaimon.common.uri_reader import UriReader, UriReaderFactory
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.table.row.blob import Blob, BlobDescriptor, BlobViewStruct
from pypaimon.table.special_fields import SpecialFields
@@ -60,6 +60,7 @@ class BlobViewLookup:
self._table = table
self._descriptor_cache: Dict[BlobViewStruct, BlobDescriptor] = {}
self._uri_reader_cache: Dict[str, UriReader] = {}
+ self._uri_reader_factory_cache: Dict[str, UriReaderFactory] = {}
self._null_value_cache: Set[BlobViewStruct] = set()
def preload(self, view_structs: List[BlobViewStruct]):
@@ -79,9 +80,7 @@ class BlobViewLookup:
if len(tasks) <= 1:
for plan, range_chunk in tasks:
- descriptors, null_values = self._load_descriptor_chunk(plan,
range_chunk)
- self._descriptor_cache.update(descriptors)
- self._null_value_cache.update(null_values)
+ self._store_chunk_results(*self._load_descriptor_chunk(plan,
range_chunk))
return
with ThreadPoolExecutor(max_workers=min(_PRELOAD_THREAD_NUM,
len(tasks))) as executor:
@@ -91,9 +90,7 @@ class BlobViewLookup:
}
for future in as_completed(futures):
try:
- descriptors, null_values = future.result()
- self._descriptor_cache.update(descriptors)
- self._null_value_cache.update(null_values)
+ self._store_chunk_results(*future.result())
except Exception as exc:
# Cancel remaining futures that have not started yet so a
single
# failure can abort the rest of the preload work as early
as possible.
@@ -119,18 +116,14 @@ class BlobViewLookup:
uri_reader = self.resolve_uri_reader(view_struct)
return Blob.from_descriptor(uri_reader, descriptor)
- def resolve_file_io(self, view_struct: BlobViewStruct):
- uri_reader = self.resolve_uri_reader(view_struct)
- if not isinstance(uri_reader, FileUriReader):
- raise ValueError(
- "Cannot resolve BlobViewStruct {} with parallel blob reads
because "
- "upstream table {} does not use a file-backed
UriReader.".format(
- view_struct, view_struct.identifier.get_full_name())
- )
- return uri_reader._file_io
-
def resolve_uri_reader(self, view_struct: BlobViewStruct) -> UriReader:
table_key = view_struct.identifier.get_full_name()
+ factory = self._uri_reader_factory_cache.get(table_key)
+ descriptor = self._descriptor_cache.get(view_struct)
+ if factory is not None and descriptor is not None:
+ # from_file_io: HTTP(S) stays on HttpUriReader; other URIs reuse
+ # the upstream table FileIO (REST table token).
+ return factory.create(descriptor.uri)
uri_reader = self._uri_reader_cache.get(table_key)
if uri_reader is None:
raise ValueError(
@@ -139,6 +132,10 @@ class BlobViewLookup:
)
return uri_reader
+ def _store_chunk_results(self, descriptors, null_values):
+ self._descriptor_cache.update(descriptors)
+ self._null_value_cache.update(null_values)
+
def resolve_to_null(self, view_struct: BlobViewStruct) -> bool:
if view_struct in self._null_value_cache:
return True
@@ -162,9 +159,11 @@ class BlobViewLookup:
def _create_table_read_plan(self, table_refs: TableReferences) ->
TableReadPlan:
upstream_table = self._load_table(table_refs.identifier)
- self._uri_reader_cache[table_refs.identifier.get_full_name()] = (
- UriReader.from_file(upstream_table.file_io)
- )
+ table_key = table_refs.identifier.get_full_name()
+ self._uri_reader_cache[table_key] = UriReader.from_file(
+ upstream_table.file_io)
+ self._uri_reader_factory_cache[table_key] = (
+ UriReaderFactory.from_file_io(upstream_table.file_io))
fields: List = []
for field_id in table_refs.references_by_field: