| # 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) |
| 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 = f"{self.table.path_factory().index_path()}/{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, |
| ) |
| return IndexManifestEntry( |
| kind=_ADD, |
| partition=partition, |
| bucket=bucket, |
| index_file=index_file, |
| ) |
| |
| def _index_file_path(self, index_file: IndexFileMeta) -> str: |
| if index_file.external_path: |
| return index_file.external_path |
| return f"{self.table.path_factory().index_path()}/{index_file.file_name}" |