blob: 3f513aabe5b4335bf815a9786de9ee026ddbb492 [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.
from __future__ import annotations
from bisect import bisect_left
from pyiceberg.expressions import EqualTo
from pyiceberg.expressions.visitors import _InclusiveMetricsEvaluator
from pyiceberg.manifest import INITIAL_SEQUENCE_NUMBER, POSITIONAL_DELETE_SCHEMA, DataFile, ManifestEntry
from pyiceberg.typedef import Record
PATH_FIELD_ID = 2147483546
class PositionDeletes:
"""Collects position delete files and indexes them by sequence number."""
__slots__ = ("_buffer", "_seqs", "_files")
def __init__(self) -> None:
self._buffer: list[tuple[DataFile, int]] | None = []
self._seqs: list[int] = []
self._files: list[tuple[DataFile, int]] = []
def add(self, delete_file: DataFile, seq_num: int) -> None:
if self._buffer is None:
raise ValueError("Cannot add files after indexing")
self._buffer.append((delete_file, seq_num))
def _ensure_indexed(self) -> None:
if self._buffer is not None:
self._files = sorted(self._buffer, key=lambda file: file[1])
self._seqs = [seq for _, seq in self._files]
self._buffer = None
def filter_by_seq(self, seq: int) -> list[DataFile]:
self._ensure_indexed()
if not self._files:
return []
start_idx = bisect_left(self._seqs, seq)
return [delete_file for delete_file, _ in self._files[start_idx:]]
def referenced_delete_files(self) -> list[DataFile]:
self._ensure_indexed()
return [data_file for data_file, _ in self._files]
def _has_path_bounds(delete_file: DataFile) -> bool:
lower = delete_file.lower_bounds
upper = delete_file.upper_bounds
if not lower or not upper:
return False
return PATH_FIELD_ID in lower and PATH_FIELD_ID in upper
def _applies_to_data_file(delete_file: DataFile, data_file: DataFile) -> bool:
if not _has_path_bounds(delete_file):
return True
evaluator = _InclusiveMetricsEvaluator(POSITIONAL_DELETE_SCHEMA, EqualTo("file_path", data_file.file_path))
return evaluator.eval(delete_file)
def _referenced_data_file_path(delete_file: DataFile) -> str | None:
"""Return the path, if the path bounds evaluate to the same location."""
lower_bounds = delete_file.lower_bounds
upper_bounds = delete_file.upper_bounds
if not lower_bounds or not upper_bounds:
return None
lower = lower_bounds.get(PATH_FIELD_ID)
upper = upper_bounds.get(PATH_FIELD_ID)
if lower and upper and lower == upper:
try:
return lower.decode("utf-8")
except (UnicodeDecodeError, AttributeError):
pass
return None
def _partition_key(spec_id: int, partition: Record | None) -> tuple[int, Record]:
if partition:
return spec_id, partition
return spec_id, Record() # unpartitioned handling
class DeleteFileIndex:
"""Indexes position delete files by partition and by exact data file path."""
def __init__(self) -> None:
self._by_partition: dict[tuple[int, Record], PositionDeletes] = {}
self._by_path: dict[str, PositionDeletes] = {}
def is_empty(self) -> bool:
return not self._by_partition and not self._by_path
def add_delete_file(self, manifest_entry: ManifestEntry, partition_key: Record | None = None) -> None:
delete_file = manifest_entry.data_file
seq = manifest_entry.sequence_number or INITIAL_SEQUENCE_NUMBER
target_path = _referenced_data_file_path(delete_file)
if target_path:
deletes = self._by_path.setdefault(target_path, PositionDeletes())
deletes.add(delete_file, seq)
else:
key = _partition_key(delete_file.spec_id or 0, partition_key)
deletes = self._by_partition.setdefault(key, PositionDeletes())
deletes.add(delete_file, seq)
def for_data_file(self, seq_num: int, data_file: DataFile, partition_key: Record | None = None) -> set[DataFile]:
if self.is_empty():
return set()
deletes: set[DataFile] = set()
spec_id = data_file.spec_id or 0
key = _partition_key(spec_id, partition_key)
partition_deletes = self._by_partition.get(key)
if partition_deletes:
for delete_file in partition_deletes.filter_by_seq(seq_num):
if _applies_to_data_file(delete_file, data_file):
deletes.add(delete_file)
path_deletes = self._by_path.get(data_file.file_path)
if path_deletes:
deletes.update(path_deletes.filter_by_seq(seq_num))
return deletes
def referenced_delete_files(self) -> list[DataFile]:
data_files: list[DataFile] = []
for deletes in self._by_partition.values():
data_files.extend(deletes.referenced_delete_files())
for deletes in self._by_path.values():
data_files.extend(deletes.referenced_delete_files())
return data_files