| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from typing import Dict, List, Set, Tuple | |
| from pypaimon.common.identifier import Identifier | |
| 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 | |
| from pypaimon.utils.range import Range | |
| _PRELOAD_THREAD_NUM = 100 | |
| _MIN_ROWS_PER_TASK = 100 | |
| class TableReferences: | |
| """Groups BlobViewStruct references by upstream table.""" | |
| def __init__(self, identifier: Identifier): | |
| self.identifier: Identifier = identifier | |
| self.references_by_field: Dict[int, List[BlobViewStruct]] = {} | |
| self.row_ids: List[int] = [] | |
| def add(self, view_struct: BlobViewStruct) -> None: | |
| self.references_by_field.setdefault(view_struct.field_id, []).append(view_struct) | |
| self.row_ids.append(int(view_struct.row_id)) | |
| class TableReadPlan: | |
| """A plan for reading blob descriptors from one upstream table.""" | |
| def __init__(self, identifier: Identifier, upstream_table, | |
| read_fields: List, row_ranges: List[Range]): | |
| self.identifier: Identifier = identifier | |
| self.upstream_table = upstream_table | |
| self.read_fields: List = read_fields | |
| self.row_ranges: List[Range] = row_ranges | |
| class BlobViewLookup: | |
| """Resolve BlobViewStruct references by reading upstream blob descriptors.""" | |
| def __init__(self, table): | |
| 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]): | |
| if not view_structs: | |
| return | |
| grouped: Dict[str, TableReferences] = self._group_by_table(view_structs) | |
| plans: List[TableReadPlan] = [] | |
| for table_refs in grouped.values(): | |
| plans.append(self._create_table_read_plan(table_refs)) | |
| target_rows: int = self._target_rows_per_task(plans) | |
| tasks: List[Tuple[TableReadPlan, List[Range]]] = [] | |
| for plan in plans: | |
| for range_chunk in self._split_row_ranges(plan.row_ranges, target_rows): | |
| tasks.append((plan, range_chunk)) | |
| if len(tasks) <= 1: | |
| for plan, range_chunk in tasks: | |
| self._store_chunk_results(*self._load_descriptor_chunk(plan, range_chunk)) | |
| return | |
| with ThreadPoolExecutor(max_workers=min(_PRELOAD_THREAD_NUM, len(tasks))) as executor: | |
| futures = { | |
| executor.submit(self._load_descriptor_chunk, plan, range_chunk): (plan, range_chunk) | |
| for plan, range_chunk in tasks | |
| } | |
| for future in as_completed(futures): | |
| try: | |
| 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. | |
| for pending_future in futures: | |
| pending_future.cancel() | |
| raise RuntimeError("Failed to preload blob descriptors.") from exc | |
| def resolve_descriptor(self, view_struct: BlobViewStruct) -> BlobDescriptor: | |
| descriptor: BlobDescriptor = self._descriptor_cache.get(view_struct) | |
| if descriptor is None: | |
| if view_struct in self._null_value_cache: | |
| raise ValueError( | |
| "BlobViewStruct {} resolves to a null blob value.".format(view_struct) | |
| ) | |
| raise ValueError( | |
| "Cannot resolve BlobViewStruct {} because row id {} was not found " | |
| "in upstream table.".format(view_struct, view_struct.row_id) | |
| ) | |
| return descriptor | |
| def resolve_blob(self, view_struct: BlobViewStruct) -> Blob: | |
| descriptor = self.resolve_descriptor(view_struct) | |
| uri_reader = self.resolve_uri_reader(view_struct) | |
| return Blob.from_descriptor(uri_reader, descriptor) | |
| 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( | |
| "Cannot resolve BlobViewStruct {} because upstream table {} " | |
| "was not loaded.".format(view_struct, table_key) | |
| ) | |
| return uri_reader | |
| def serialize_view_field_value(self, value): | |
| """Replace BlobViewStruct bytes with descriptor bytes for Arrow output.""" | |
| 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) | |
| if not (isinstance(value, bytes) and BlobViewStruct.is_blob_view_struct(value)): | |
| return value | |
| view_struct = BlobViewStruct.deserialize(value) | |
| if self.resolve_to_null(view_struct): | |
| return None | |
| return self.resolve_descriptor(view_struct).serialize() | |
| 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 | |
| if view_struct not in self._descriptor_cache: | |
| raise ValueError( | |
| "Cannot resolve BlobViewStruct {} because row id {} was not found " | |
| "in upstream table.".format(view_struct, view_struct.row_id) | |
| ) | |
| return False | |
| def _group_by_table( | |
| self, view_structs: List[BlobViewStruct] | |
| ) -> Dict[str, TableReferences]: | |
| grouped: Dict[str, TableReferences] = {} | |
| for view_struct in view_structs: | |
| key = view_struct.identifier.get_full_name() | |
| if key not in grouped: | |
| grouped[key] = TableReferences(view_struct.identifier) | |
| grouped[key].add(view_struct) | |
| return grouped | |
| def _create_table_read_plan(self, table_refs: TableReferences) -> TableReadPlan: | |
| upstream_table = self._load_table(table_refs.identifier) | |
| 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: | |
| fields.append(self._field_by_id(upstream_table, field_id)) | |
| read_fields = SpecialFields.row_type_with_row_id(fields) | |
| return TableReadPlan( | |
| table_refs.identifier, upstream_table, read_fields, | |
| Range.to_ranges(table_refs.row_ids)) | |
| def _load_descriptor_chunk( | |
| self, plan: TableReadPlan, row_ranges: List[Range] | |
| ) -> Tuple[Dict[BlobViewStruct, BlobDescriptor], set]: | |
| identifier: Identifier = plan.identifier | |
| upstream_table = plan.upstream_table | |
| read_fields = plan.read_fields | |
| projection_field_names: List[str] = [f.name for f in read_fields] | |
| descriptor_table = upstream_table.copy_without_time_travel({ | |
| CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true", | |
| 'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'}) | |
| read_builder = descriptor_table.new_read_builder().with_projection(projection_field_names) | |
| if SpecialFields.ROW_ID.name not in [ | |
| data_field.name for data_field in read_builder.read_type() | |
| ]: | |
| raise ValueError( | |
| "Cannot resolve blob view for table {} because row tracking is not readable." | |
| .format(identifier.get_full_name()) | |
| ) | |
| predicate_builder = read_builder.new_predicate_builder() | |
| range_predicates: List = [] | |
| for r in row_ranges: | |
| if r.from_ == r.to: | |
| range_predicates.append( | |
| predicate_builder.equal(SpecialFields.ROW_ID.name, r.from_)) | |
| else: | |
| range_predicates.append( | |
| predicate_builder.between(SpecialFields.ROW_ID.name, r.from_, r.to)) | |
| if len(range_predicates) == 1: | |
| predicate = range_predicates[0] | |
| else: | |
| predicate = predicate_builder.or_predicates(range_predicates) | |
| read_builder.with_filter(predicate) | |
| result = read_builder.new_read().to_arrow(read_builder.new_scan().plan().splits()) | |
| if SpecialFields.ROW_ID.name not in result.schema.names: | |
| raise ValueError( | |
| "Cannot resolve blob view for table {} because row tracking is not readable." | |
| .format(identifier.get_full_name()) | |
| ) | |
| row_id_values: List = result.column(SpecialFields.ROW_ID.name).to_pylist() | |
| resolved: Dict[BlobViewStruct, BlobDescriptor] = {} | |
| null_values: set = set() | |
| for field in read_fields: | |
| if field.name == SpecialFields.ROW_ID.name: | |
| continue | |
| if field.name not in result.schema.names: | |
| continue | |
| values = result.column(field.name).to_pylist() | |
| for row_id, value in zip(row_id_values, values): | |
| view_struct = BlobViewStruct( | |
| identifier.get_full_name(), field.id, int(row_id)) | |
| if value is None: | |
| null_values.add(view_struct) | |
| continue | |
| descriptor = BlobDescriptor.deserialize(value) | |
| resolved[view_struct] = descriptor | |
| return resolved, null_values | |
| @staticmethod | |
| def _split_row_ranges( | |
| row_ranges: List[Range], target_rows_per_task: int | |
| ) -> List[List[Range]]: | |
| """ | |
| Split row ranges into multiple chunks for parallel task processing. | |
| """ | |
| if not row_ranges: | |
| return [] | |
| chunks: List[List[Range]] = [] | |
| current_chunk: List[Range] = [] | |
| current_chunk_rows: int = 0 | |
| for r in row_ranges: | |
| next_from = r.from_ | |
| # Process current range until all rows are allocated | |
| while next_from <= r.to: | |
| # If current chunk is full, save it and start a new one | |
| if current_chunk_rows == target_rows_per_task: | |
| chunks.append(current_chunk) | |
| current_chunk = [] | |
| current_chunk_rows = 0 | |
| # Calculate remaining capacity in current chunk | |
| remaining = target_rows_per_task - current_chunk_rows | |
| # Determine the end position for this allocation (don't exceed range boundary) | |
| next_to = min(r.to, next_from + remaining - 1) | |
| # Add the allocated range to current chunk | |
| current_chunk.append(Range(next_from, next_to)) | |
| current_chunk_rows += next_to - next_from + 1 | |
| # Move to next unallocated position | |
| next_from = next_to + 1 | |
| # Don't forget the last chunk if it has any ranges | |
| if current_chunk: | |
| chunks.append(current_chunk) | |
| return chunks | |
| @staticmethod | |
| def _target_rows_per_task(plans: List[TableReadPlan]) -> int: | |
| total_rows: int = 0 | |
| for plan in plans: | |
| for r in plan.row_ranges: | |
| total_rows += r.count() | |
| if total_rows <= 0: | |
| return _MIN_ROWS_PER_TASK | |
| return max(_MIN_ROWS_PER_TASK, (total_rows + _PRELOAD_THREAD_NUM - 1) // _PRELOAD_THREAD_NUM) | |
| def _load_table(self, identifier: Identifier): | |
| catalog_environment = self._table.catalog_environment | |
| catalog_loader = catalog_environment.catalog_loader | |
| dependency_context = catalog_environment.dependency_read_context() | |
| if dependency_context is catalog_environment.catalog_context(): | |
| catalog = catalog_loader.load() | |
| else: | |
| from pypaimon.catalog.catalog_factory import CatalogFactory | |
| catalog = CatalogFactory.create_from_context( | |
| dependency_context, config_required=False) | |
| return catalog.get_table(identifier) | |
| @staticmethod | |
| def _field_by_id(table, field_id: int) -> 'DataField': | |
| for field in table.table_schema.fields: | |
| if field.id == field_id: | |
| return field | |
| raise ValueError( | |
| "Cannot find blob fieldId {} in upstream table {}." | |
| .format(field_id, table.identifier.get_full_name()) | |
| ) |