| # 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. | |
| """Reusable global index build planning helpers.""" | |
| from typing import List, Optional, Sequence | |
| from pypaimon.globalindex.indexed_split import IndexedSplit | |
| from pypaimon.read.split import DataSplit | |
| from pypaimon.utils.range import Range | |
| from pypaimon.utils.range_helper import RangeHelper | |
| def calc_row_range(split) -> Range: | |
| ranges = [] | |
| for file in split.files: | |
| row_range = file.row_id_range() | |
| if row_range is None: | |
| raise ValueError( | |
| "Cannot build global index because file '%s' has no row id range." | |
| % file.file_name | |
| ) | |
| ranges.append(row_range) | |
| if not ranges: | |
| raise ValueError("Cannot build global index for an empty split.") | |
| merged = Range.sort_and_merge_overlap(ranges, True, True) | |
| return Range(merged[0].from_, merged[-1].to) | |
| def unindexed_row_ranges( | |
| table, | |
| snapshot, | |
| partition_filter, | |
| index_field_id: int, | |
| index_type: str, | |
| ) -> List[Range]: | |
| next_row_id = getattr(snapshot, "next_row_id", None) | |
| if snapshot is None or next_row_id is None or next_row_id <= 0: | |
| return [] | |
| indexed_ranges = indexed_row_ranges( | |
| table, snapshot, partition_filter, index_field_id, index_type) | |
| return Range.sort_and_merge_overlap( | |
| Range(0, next_row_id - 1).exclude(indexed_ranges), True) | |
| def indexed_row_ranges( | |
| table, | |
| snapshot, | |
| partition_filter, | |
| index_field_id: int, | |
| index_type: str, | |
| ) -> List[Range]: | |
| from pypaimon.index.index_file_handler import IndexFileHandler | |
| ranges = [] | |
| for entry in IndexFileHandler(table).scan(snapshot): | |
| if getattr(entry, "kind", 0) != 0: | |
| continue | |
| if (partition_filter is not None | |
| and not partition_filter.test(entry.partition)): | |
| continue | |
| index_file = entry.index_file | |
| meta = index_file.global_index_meta | |
| if ( | |
| meta is None | |
| or index_file.index_type != index_type | |
| or meta.index_field_id != index_field_id | |
| or meta.extra_field_ids | |
| ): | |
| continue | |
| ranges.append(Range(meta.row_range_start, meta.row_range_end)) | |
| return Range.sort_and_merge_overlap(ranges, True) | |
| def split_by_contiguous_row_range(splits): | |
| result = [] | |
| for split in splits: | |
| result.extend(split_one_by_contiguous_row_range(split)) | |
| return result | |
| def split_by_contiguous_unindexed_row_range(splits, unindexed_ranges): | |
| result = [] | |
| for split in split_by_contiguous_row_range(splits): | |
| row_range = calc_row_range(split) | |
| for index_range in Range.and_([row_range], unindexed_ranges): | |
| index_split = indexed_split_for_row_range(split, index_range) | |
| if index_split is not None: | |
| result.append((index_split, index_range)) | |
| return result | |
| def indexed_split_for_row_range(split, row_range): | |
| files = [ | |
| file for file in split.files | |
| if file.row_id_range() is not None | |
| and file.row_id_range().overlaps(row_range) | |
| ] | |
| if not files: | |
| return None | |
| return IndexedSplit(copy_split_with_files(split, files), [row_range]) | |
| def filter_non_indexable_splits(table, splits, index_columns: Sequence[str]): | |
| boundary = find_min_non_indexable_row_id( | |
| table.schema_manager, | |
| [file for split in splits for file in split.files], | |
| index_columns, | |
| ) | |
| if boundary is None: | |
| return splits | |
| result = [] | |
| for split in splits: | |
| files = [ | |
| file for file in split.files | |
| if file.row_id_range() is not None | |
| and file.row_id_range().from_ < boundary | |
| ] | |
| if files: | |
| result.append(copy_split_with_files(split, files)) | |
| return result | |
| def find_min_non_indexable_row_id(schema_manager, files, index_columns): | |
| schema_contains_columns = {} | |
| index_column_set = set(index_columns) | |
| boundary = None | |
| for file in files: | |
| row_range = file.row_id_range() | |
| if row_range is None: | |
| continue | |
| schema_id = file.schema_id | |
| if schema_id not in schema_contains_columns: | |
| schema = schema_manager.get_schema(schema_id) | |
| schema_field_names = {field.name for field in schema.fields} | |
| schema_contains_columns[schema_id] = ( | |
| index_column_set.issubset(schema_field_names) | |
| ) | |
| if not schema_contains_columns[schema_id]: | |
| if boundary is None or row_range.from_ < boundary: | |
| boundary = row_range.from_ | |
| return boundary | |
| def split_by_global_index_shard( | |
| splits, | |
| rows_per_shard: int, | |
| row_ranges_to_build: Optional[List[Range]] = None, | |
| ): | |
| if rows_per_shard <= 0: | |
| raise ValueError( | |
| "Option 'global-index.row-count-per-shard' must be greater than 0." | |
| ) | |
| if row_ranges_to_build is not None: | |
| row_ranges_to_build = Range.sort_and_merge_overlap( | |
| row_ranges_to_build, True) | |
| if not row_ranges_to_build: | |
| return [] | |
| groups = {} | |
| for split in splits: | |
| key = (partition_key(split.partition), split.bucket) | |
| if key not in groups: | |
| groups[key] = { | |
| "partition": split.partition, | |
| "bucket": split.bucket, | |
| "files": [], | |
| } | |
| for file in split.files: | |
| if file.row_id_range() is None: | |
| continue | |
| groups[key]["files"].append(file) | |
| result = [] | |
| for group in groups.values(): | |
| files_by_shard = {} | |
| for file in group["files"]: | |
| file_range = file.row_id_range() | |
| start_shard = file_range.from_ // rows_per_shard | |
| end_shard = file_range.to // rows_per_shard | |
| for shard_id in range(start_shard, end_shard + 1): | |
| shard_start = shard_id * rows_per_shard | |
| files_by_shard.setdefault(shard_start, []).append(file) | |
| for shard_start in sorted(files_by_shard): | |
| shard_end = shard_start + rows_per_shard - 1 | |
| shard_files = sorted( | |
| files_by_shard[shard_start], | |
| key=lambda file: file.row_id_range().from_, | |
| ) | |
| current_group = [] | |
| current_group_end = None | |
| for file in shard_files: | |
| file_range = file.row_id_range() | |
| if not current_group: | |
| current_group.append(file) | |
| current_group_end = file_range.to | |
| elif file_range.from_ <= current_group_end + 1: | |
| current_group.append(file) | |
| current_group_end = max(current_group_end, file_range.to) | |
| else: | |
| append_shard_split( | |
| result, | |
| current_group, | |
| shard_start, | |
| shard_end, | |
| group["partition"], | |
| group["bucket"], | |
| row_ranges_to_build, | |
| ) | |
| current_group = [file] | |
| current_group_end = file_range.to | |
| if current_group: | |
| append_shard_split( | |
| result, | |
| current_group, | |
| shard_start, | |
| shard_end, | |
| group["partition"], | |
| group["bucket"], | |
| row_ranges_to_build, | |
| ) | |
| return result | |
| def partition_key(partition): | |
| values = getattr(partition, "values", None) | |
| return tuple(values) if values is not None else partition | |
| def append_shard_split( | |
| result, | |
| files, | |
| shard_start, | |
| shard_end, | |
| partition, | |
| bucket, | |
| row_ranges_to_build, | |
| ): | |
| group_start = min(file.row_id_range().from_ for file in files) | |
| group_end = max(file.row_id_range().to for file in files) | |
| row_range = Range(max(group_start, shard_start), min(group_end, shard_end)) | |
| task_ranges = ( | |
| [row_range] | |
| if row_ranges_to_build is None | |
| else Range.and_([row_range], row_ranges_to_build) | |
| ) | |
| if not task_ranges: | |
| return | |
| # Index the physical row-ID range, including DV-deleted rows, as in Java. | |
| # Query-side live-row filtering applies DVs for the pinned query snapshot. | |
| # Passing deletion files here would change the source coverage seen by writers. | |
| data_split = DataSplit( | |
| files=list(files), | |
| partition=partition, | |
| bucket=bucket, | |
| raw_convertible=False, | |
| ) | |
| for task_range in task_ranges: | |
| result.append((IndexedSplit(data_split, [task_range]), task_range)) | |
| def split_one_by_contiguous_row_range(split): | |
| for file in split.files: | |
| if file.row_id_range() is None: | |
| raise ValueError( | |
| "Cannot build global index because file '%s' has no row id range." | |
| % file.file_name | |
| ) | |
| range_helper = RangeHelper(lambda file: file.row_id_range()) | |
| ranges = range_helper.merge_overlapping_ranges(split.files) | |
| if not ranges: | |
| return [] | |
| result = [] | |
| current_segment = [] | |
| current_max_row_id = None | |
| for range_files in ranges: | |
| min_row_id = min(file.row_id_range().from_ for file in range_files) | |
| max_row_id = max(file.row_id_range().to for file in range_files) | |
| if ( | |
| not current_segment | |
| or current_max_row_id is None | |
| or current_max_row_id >= min_row_id - 1 | |
| ): | |
| current_segment.extend(range_files) | |
| current_max_row_id = max_row_id | |
| else: | |
| result.append(copy_split_with_files(split, current_segment)) | |
| current_segment = list(range_files) | |
| current_max_row_id = max_row_id | |
| if current_segment: | |
| result.append(copy_split_with_files(split, current_segment)) | |
| return result | |
| def copy_split_with_files(split, files): | |
| # Like Java's splitByContiguousRowRange, build over physical row IDs. | |
| # Query readers apply the deletion vectors from their own snapshot. | |
| return DataSplit( | |
| files=list(files), | |
| partition=split.partition, | |
| bucket=split.bucket, | |
| raw_convertible=getattr(split, "raw_convertible", False), | |
| ) |