| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| import uuid | |
| from bisect import bisect_right | |
| from dataclasses import dataclass | |
| from typing import Dict, List, Optional, Sequence, Set, Tuple | |
| from pypaimon.deletionvectors.bitmap_deletion_vector import BitmapDeletionVector | |
| from pypaimon.deletionvectors.deletion_vector import DeletionVector | |
| from pypaimon.index.deletion_vector_meta import DeletionVectorMeta | |
| from pypaimon.index.index_file_meta import IndexFileMeta | |
| from pypaimon.manifest.index_manifest_entry import IndexManifestEntry | |
| from pypaimon.manifest.index_manifest_file import IndexManifestFile | |
| from pypaimon.manifest.schema.data_file_meta import DataFileMeta | |
| from pypaimon.read.scanner.data_evolution_split_generator import ( | |
| DataEvolutionSplitGenerator, | |
| ) | |
| from pypaimon.table.row.generic_row import GenericRow | |
| from pypaimon.table.source.deletion_file import DeletionFile | |
| from pypaimon.utils.data_evolution_utils import retrieve_anchor_file | |
| from pypaimon.utils.file_store_path_factory import FileStorePathFactory | |
| from pypaimon.utils.range import Range | |
| from pypaimon.write.commit_message import CommitMessage | |
| _ADD = 0 | |
| _DELETE = 1 | |
| _DV_INDEX_VERSION = 1 | |
| @dataclass(frozen=True) | |
| class _AnchorRange: | |
| row_range: Range | |
| partition: GenericRow | |
| bucket: int | |
| anchor_file: DataFileMeta | |
| @dataclass(frozen=True) | |
| class _AnchorRangesInfo: | |
| snapshot_id: int | |
| anchors: List[_AnchorRange] | |
| class TableDeleteByRowId: | |
| """Write data-evolution deletes as deletion-vector index updates.""" | |
| def __init__( | |
| self, | |
| table, | |
| _precomputed_anchor_ranges: Optional[_AnchorRangesInfo] = None, | |
| ): | |
| from pypaimon.table.file_store_table import FileStoreTable | |
| self.table: FileStoreTable = table | |
| self.file_io = table.file_io | |
| self._precomputed_anchor_ranges = _precomputed_anchor_ranges | |
| def _snapshot_anchor_ranges(self) -> _AnchorRangesInfo: | |
| snapshot_id, anchors = self._scan_anchor_ranges() | |
| return _AnchorRangesInfo(snapshot_id=snapshot_id, anchors=anchors) | |
| def delete(self, row_ids: Sequence[int]) -> List[CommitMessage]: | |
| self._validate_delete() | |
| deduplicated = self._deduplicate_row_ids(row_ids) | |
| if not deduplicated: | |
| return [] | |
| snapshot_id, anchors = self._load_anchor_ranges() | |
| targets = self._group_delete_targets(deduplicated, anchors) | |
| commit_messages = [] | |
| for (partition, bucket), file_positions in targets.items(): | |
| message = self._build_commit_message( | |
| partition, | |
| bucket, | |
| file_positions, | |
| snapshot_id, | |
| ) | |
| if message is not None: | |
| commit_messages.append(message) | |
| return commit_messages | |
| def _validate_delete(self): | |
| if not self.table.options.data_evolution_enabled(): | |
| raise ValueError( | |
| "delete requires 'data-evolution.enabled' = 'true'." | |
| ) | |
| if not self.table.options.row_tracking_enabled(): | |
| raise ValueError( | |
| "delete requires 'row-tracking.enabled' = 'true'." | |
| ) | |
| if not self.table.options.deletion_vectors_enabled(False): | |
| raise ValueError( | |
| "delete requires 'deletion-vectors.enabled' = 'true'." | |
| ) | |
| @staticmethod | |
| def _deduplicate_row_ids(row_ids: Sequence[int]) -> List[int]: | |
| result = [] | |
| seen = set() | |
| for row_id in row_ids: | |
| if row_id is None: | |
| raise ValueError("_ROW_ID value must not be null.") | |
| row_id = int(row_id) | |
| if row_id not in seen: | |
| result.append(row_id) | |
| seen.add(row_id) | |
| return result | |
| def _load_anchor_ranges(self) -> Tuple[int, List[_AnchorRange]]: | |
| if self._precomputed_anchor_ranges is not None: | |
| return ( | |
| self._precomputed_anchor_ranges.snapshot_id, | |
| self._precomputed_anchor_ranges.anchors, | |
| ) | |
| return self._scan_anchor_ranges() | |
| def _scan_anchor_ranges(self) -> Tuple[int, List[_AnchorRange]]: | |
| scan = self.table.new_read_builder().new_scan() | |
| plan = scan.plan_for_write() | |
| snapshot_id = plan.snapshot_id if plan.snapshot_id is not None else -1 | |
| anchors = [] | |
| for split in plan.splits(): | |
| files = [ | |
| file | |
| for file in split.files | |
| if file.row_id_range() is not None | |
| ] | |
| for group in DataEvolutionSplitGenerator._split_by_row_id(files): | |
| anchor = retrieve_anchor_file(group) | |
| anchors.append( | |
| _AnchorRange( | |
| row_range=anchor.row_id_range(), | |
| partition=split.partition, | |
| bucket=split.bucket, | |
| anchor_file=anchor, | |
| ) | |
| ) | |
| anchors.sort(key=lambda item: item.row_range.from_) | |
| return snapshot_id, anchors | |
| def _group_delete_targets( | |
| self, | |
| row_ids: Sequence[int], | |
| anchors: List[_AnchorRange], | |
| ) -> Dict[Tuple[Tuple, int], Dict[str, Set[int]]]: | |
| starts = [anchor.row_range.from_ for anchor in anchors] | |
| targets: Dict[Tuple[Tuple, int], Dict[str, Set[int]]] = {} | |
| for row_id in row_ids: | |
| index = bisect_right(starts, row_id) - 1 | |
| if index < 0 or not anchors[index].row_range.contains(row_id): | |
| raise ValueError( | |
| f"Row ID {row_id} does not belong to any valid range " | |
| f"{[f'[{a.row_range.from_}, {a.row_range.to}]' for a in anchors]}" | |
| ) | |
| anchor = anchors[index] | |
| partition_tuple = tuple(anchor.partition.values) | |
| key = (partition_tuple, anchor.bucket) | |
| file_positions = targets.setdefault(key, {}) | |
| positions = file_positions.setdefault(anchor.anchor_file.file_name, set()) | |
| positions.add(row_id - anchor.row_range.from_) | |
| return targets | |
| def _build_commit_message( | |
| self, | |
| partition: Tuple, | |
| bucket: int, | |
| file_positions: Dict[str, Set[int]], | |
| snapshot_id: int, | |
| ) -> Optional[CommitMessage]: | |
| partition_row = GenericRow(list(partition), self.table.partition_keys_fields) | |
| old_entries, deletion_vectors = self._read_existing_deletion_vectors( | |
| partition_row, | |
| bucket, | |
| snapshot_id, | |
| ) | |
| changed = False | |
| for file_name, positions in file_positions.items(): | |
| deletion_vector = deletion_vectors.setdefault( | |
| file_name, | |
| BitmapDeletionVector(), | |
| ) | |
| for position in positions: | |
| changed = deletion_vector.checked_delete(position) or changed | |
| if not changed: | |
| return None | |
| new_entry = self._write_deletion_vector_index( | |
| partition_row, | |
| bucket, | |
| deletion_vectors, | |
| ) | |
| delete_entries = [ | |
| IndexManifestEntry( | |
| kind=_DELETE, | |
| partition=entry.partition, | |
| bucket=entry.bucket, | |
| index_file=entry.index_file, | |
| ) | |
| for entry in old_entries | |
| ] | |
| return CommitMessage( | |
| partition=partition, | |
| bucket=bucket, | |
| new_files=[], | |
| check_from_snapshot=snapshot_id, | |
| index_adds=[new_entry], | |
| index_deletes=delete_entries, | |
| ) | |
| def _read_existing_deletion_vectors( | |
| self, | |
| partition: GenericRow, | |
| bucket: int, | |
| snapshot_id: int, | |
| ) -> Tuple[List[IndexManifestEntry], Dict[str, DeletionVector]]: | |
| snapshot_manager = self.table.snapshot_manager() | |
| snapshot = ( | |
| snapshot_manager.get_snapshot_by_id(snapshot_id) | |
| if snapshot_id >= 0 else snapshot_manager.get_latest_snapshot() | |
| ) | |
| if snapshot is None or not snapshot.index_manifest: | |
| return [], {} | |
| index_manifest_file = IndexManifestFile(self.table) | |
| entries = [] | |
| deletion_vectors: Dict[str, DeletionVector] = {} | |
| for entry in index_manifest_file.read(snapshot.index_manifest): | |
| if entry.kind != _ADD: | |
| continue | |
| if entry.index_file.index_type != IndexManifestFile.DELETION_VECTORS_INDEX: | |
| continue | |
| if entry.bucket != bucket or entry.partition != partition: | |
| continue | |
| entries.append(entry) | |
| if not entry.index_file.dv_ranges: | |
| continue | |
| dv_path = self._index_file_path(entry.index_file, partition, bucket) | |
| for data_file_name, meta in entry.index_file.dv_ranges.items(): | |
| deletion_file = DeletionFile( | |
| dv_index_path=dv_path, | |
| offset=meta.offset, | |
| length=meta.length, | |
| cardinality=meta.cardinality, | |
| ) | |
| deletion_vectors[data_file_name] = DeletionVector.read( | |
| self.file_io, | |
| deletion_file, | |
| ) | |
| return entries, deletion_vectors | |
| def _write_deletion_vector_index( | |
| self, | |
| partition: GenericRow, | |
| bucket: int, | |
| deletion_vectors: Dict[str, DeletionVector], | |
| ) -> IndexManifestEntry: | |
| file_name = f"{FileStorePathFactory.INDEX_PREFIX}{uuid.uuid4()}-1" | |
| path, external = self.table.path_factory().new_bucket_index_path( | |
| tuple(partition.values), bucket, file_name) | |
| position = 1 | |
| dv_ranges = {} | |
| chunks = [bytes([_DV_INDEX_VERSION])] | |
| for data_file_name in sorted(deletion_vectors): | |
| deletion_vector = deletion_vectors[data_file_name] | |
| serialized = deletion_vector.serialize() | |
| bitmap_length = int.from_bytes(serialized[:4], byteorder="big") | |
| dv_ranges[data_file_name] = DeletionVectorMeta( | |
| data_file_name=data_file_name, | |
| offset=position, | |
| length=bitmap_length, | |
| cardinality=deletion_vector.get_cardinality(), | |
| ) | |
| chunks.append(serialized) | |
| position += len(serialized) | |
| data = b"".join(chunks) | |
| try: | |
| with self.file_io.new_output_stream(path) as output_stream: | |
| output_stream.write(data) | |
| except Exception: | |
| self.file_io.delete_quietly(path) | |
| raise | |
| index_file = IndexFileMeta( | |
| index_type=IndexManifestFile.DELETION_VECTORS_INDEX, | |
| file_name=file_name, | |
| file_size=len(data), | |
| row_count=len(dv_ranges), | |
| dv_ranges=dv_ranges, | |
| external_path=path if external else None, | |
| ) | |
| return IndexManifestEntry( | |
| kind=_ADD, | |
| partition=partition, | |
| bucket=bucket, | |
| index_file=index_file, | |
| ) | |
| def _index_file_path(self, index_file: IndexFileMeta, partition: GenericRow, bucket: int) -> str: | |
| return self.table.path_factory().bucket_index_path( | |
| tuple(partition.values), bucket, index_file, self.file_io) |