|
15 | 15 | # specific language governing permissions and limitations |
16 | 16 | # under the License. |
17 | 17 | import math |
18 | | -from typing import TYPE_CHECKING |
| 18 | +import struct |
| 19 | +import zlib |
| 20 | +from typing import TYPE_CHECKING, cast |
19 | 21 |
|
20 | 22 | from pyroaring import BitMap, FrozenBitMap |
21 | 23 |
|
|
24 | 26 | if TYPE_CHECKING: |
25 | 27 | import pyarrow as pa |
26 | 28 |
|
| 29 | + from pyiceberg.io import FileIO |
| 30 | + from pyiceberg.manifest import DataFile |
| 31 | + |
27 | 32 | EMPTY_BITMAP = FrozenBitMap() |
28 | 33 | MAX_JAVA_SIGNED = int(math.pow(2, 31)) - 1 |
29 | 34 | PROPERTY_REFERENCED_DATA_FILE = "referenced-data-file" |
| 35 | +_MAX_DELETION_VECTOR_CONTENT_SIZE = 2**31 - 1 |
| 36 | +_DV_BLOB_LENGTH = struct.Struct(">I") |
| 37 | +_DV_BLOB_MAGIC = struct.Struct("<I") |
| 38 | +_DV_BLOB_CRC = struct.Struct(">I") |
| 39 | +_DV_BLOB_MAGIC_NUMBER = 1681511377 |
| 40 | +_ROARING_BITMAP_COUNT_SIZE_BYTES = 8 |
| 41 | +_DV_BLOB_MIN_SIZE_BYTES = _DV_BLOB_LENGTH.size + _DV_BLOB_MAGIC.size + _ROARING_BITMAP_COUNT_SIZE_BYTES + _DV_BLOB_CRC.size |
30 | 42 |
|
31 | 43 |
|
32 | 44 | class DeletionVector: |
@@ -77,17 +89,99 @@ def to_vector(self) -> "pa.ChunkedArray": |
77 | 89 | return self._bitmaps_to_chunked_array(self._bitmaps) |
78 | 90 |
|
79 | 91 |
|
80 | | -def _extract_vector_payload(blob_payload: bytes) -> bytes: |
81 | | - """Strip deletion-vector-v1 blob framing: length(4 big-endian) + DV magic(4) ... CRC(4 big-endian).""" |
82 | | - length_prefix = int.from_bytes(blob_payload[0:4], "big") |
83 | | - return blob_payload[8 : 4 + length_prefix] |
| 92 | +def _deserialize_dv_blob(blob: bytes, record_count: int | None = None) -> list[BitMap]: |
| 93 | + # The DV blob encoding matches Iceberg Java's BitmapPositionDeleteIndex: |
| 94 | + # 4-byte big-endian bitmap-data length, 4-byte little-endian magic number, |
| 95 | + # portable Roaring bitmap data, and 4-byte big-endian CRC-32. |
| 96 | + if len(blob) < _DV_BLOB_MIN_SIZE_BYTES: |
| 97 | + raise ValueError(f"Invalid deletion vector blob length: {len(blob)}") |
| 98 | + |
| 99 | + bitmap_data_length = _DV_BLOB_LENGTH.unpack_from(blob)[0] |
| 100 | + expected_bitmap_data_length = len(blob) - _DV_BLOB_LENGTH.size - _DV_BLOB_CRC.size |
| 101 | + if bitmap_data_length != expected_bitmap_data_length: |
| 102 | + raise ValueError(f"Invalid bitmap data length: {bitmap_data_length}, expected {expected_bitmap_data_length}") |
| 103 | + |
| 104 | + bitmap_data_offset = _DV_BLOB_LENGTH.size |
| 105 | + crc_offset = bitmap_data_offset + bitmap_data_length |
| 106 | + bitmap_data = blob[bitmap_data_offset:crc_offset] |
| 107 | + |
| 108 | + magic_number = _DV_BLOB_MAGIC.unpack_from(bitmap_data)[0] |
| 109 | + if magic_number != _DV_BLOB_MAGIC_NUMBER: |
| 110 | + raise ValueError(f"Invalid magic number: {magic_number}, expected {_DV_BLOB_MAGIC_NUMBER}") |
| 111 | + |
| 112 | + checksum = zlib.crc32(bitmap_data) & 0xFFFFFFFF |
| 113 | + expected_checksum = _DV_BLOB_CRC.unpack_from(blob, crc_offset)[0] |
| 114 | + if checksum != expected_checksum: |
| 115 | + raise ValueError("Invalid CRC") |
| 116 | + |
| 117 | + bitmaps = DeletionVector._deserialize_bitmap(bitmap_data[_DV_BLOB_MAGIC.size :]) |
| 118 | + if record_count is not None: |
| 119 | + cardinality = sum(len(bitmap) for bitmap in bitmaps) |
| 120 | + if cardinality != record_count: |
| 121 | + raise ValueError(f"Invalid cardinality: {cardinality}, expected {record_count}") |
| 122 | + |
| 123 | + return bitmaps |
| 124 | + |
| 125 | + |
| 126 | +def _validate_deletion_vector_content(dv: "DataFile") -> None: |
| 127 | + content_offset = dv.content_offset |
| 128 | + content_size_in_bytes = dv.content_size_in_bytes |
| 129 | + referenced_data_file = dv.referenced_data_file |
| 130 | + |
| 131 | + if content_offset is None: |
| 132 | + raise ValueError(f"Invalid deletion vector, content offset is missing: {dv.file_path}") |
| 133 | + if content_size_in_bytes is None: |
| 134 | + raise ValueError(f"Invalid deletion vector, content size is missing: {dv.file_path}") |
| 135 | + if content_offset < 0: |
| 136 | + raise ValueError(f"Invalid deletion vector, content offset cannot be negative: {content_offset}") |
| 137 | + if content_size_in_bytes < 0: |
| 138 | + raise ValueError(f"Invalid deletion vector, content size cannot be negative: {content_size_in_bytes}") |
| 139 | + if content_size_in_bytes > _MAX_DELETION_VECTOR_CONTENT_SIZE: |
| 140 | + raise ValueError(f"Cannot read deletion vector larger than 2GB: {content_size_in_bytes}") |
| 141 | + if referenced_data_file is None: |
| 142 | + raise ValueError(f"Invalid deletion vector, referenced data file is missing: {dv.file_path}") |
| 143 | + |
| 144 | + |
| 145 | +def has_deletion_vector_content_reference(dv: "DataFile") -> bool: |
| 146 | + """Return whether a deletion vector is described by manifest content-range metadata.""" |
| 147 | + return dv.content_offset is not None or dv.content_size_in_bytes is not None or dv.referenced_data_file is not None |
| 148 | + |
| 149 | + |
| 150 | +def _read_deletion_vector(io: "FileIO", dv: "DataFile") -> DeletionVector: |
| 151 | + _validate_deletion_vector_content(dv) |
| 152 | + |
| 153 | + content_offset = cast(int, dv.content_offset) |
| 154 | + content_size_in_bytes = cast(int, dv.content_size_in_bytes) |
| 155 | + referenced_data_file = cast(str, dv.referenced_data_file) |
| 156 | + |
| 157 | + with io.new_input(dv.file_path).open() as fi: |
| 158 | + fi.seek(content_offset) |
| 159 | + payload = fi.read(content_size_in_bytes) |
| 160 | + |
| 161 | + if len(payload) != content_size_in_bytes: |
| 162 | + raise ValueError(f"Could not read deletion vector, expected {content_size_in_bytes} bytes, got {len(payload)}") |
| 163 | + |
| 164 | + return DeletionVector( |
| 165 | + referenced_data_file=referenced_data_file, |
| 166 | + bitmaps=_deserialize_dv_blob(payload, dv.record_count), |
| 167 | + ) |
| 168 | + |
| 169 | + |
| 170 | +def read_deletion_vectors(io: "FileIO", dv: "DataFile") -> list[DeletionVector]: |
| 171 | + """Read deletion vectors from a delete file or its manifest content range.""" |
| 172 | + if has_deletion_vector_content_reference(dv): |
| 173 | + return [_read_deletion_vector(io, dv)] |
| 174 | + |
| 175 | + with io.new_input(dv.file_path).open() as fi: |
| 176 | + return deletion_vectors_from_puffin_file(PuffinFile(fi.read())) |
84 | 177 |
|
85 | 178 |
|
86 | 179 | def deletion_vectors_from_puffin_file(puffin_file: PuffinFile) -> list[DeletionVector]: |
| 180 | + """Read all deletion vectors stored in a Puffin file.""" |
87 | 181 | return [ |
88 | 182 | DeletionVector( |
89 | 183 | referenced_data_file=blob.properties[PROPERTY_REFERENCED_DATA_FILE], |
90 | | - bitmaps=DeletionVector._deserialize_bitmap(_extract_vector_payload(puffin_file.get_blob_payload(blob))), |
| 184 | + bitmaps=_deserialize_dv_blob(puffin_file.get_blob_payload(blob)), |
91 | 185 | ) |
92 | 186 | for blob in puffin_file.footer.blobs |
93 | 187 | ] |
0 commit comments