blob: 5c475c31b1f69cda53efe513d6cf1120ca03fbe8 [file]
# 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}"