Skip to content

Commit 2949666

Browse files
committed
Address review: run-optimize DV bitmaps, cap positions, skip write on error
- DeletionVector._serialize_bitmap now run-optimizes each bitmap before serializing, matching Java's BitmapPositionDeleteIndex (a contiguous 100k-position DV drops from ~16 KB to ~25 bytes; still round-trips). - from_positions rejects positions above MAX_POSITION, mirroring Java's RoaringPositionBitmap.MAX_POSITION, to avoid an OOM in the gap-fill. - PuffinWriter.__exit__ short-circuits when the with-body raised, so no half-populated file is written. Co-authored-by: Isaac
1 parent 72d67d9 commit 2949666

4 files changed

Lines changed: 44 additions & 3 deletions

File tree

pyiceberg/table/deletion_vector.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929

3030
EMPTY_BITMAP = FrozenBitMap()
3131
MAX_JAVA_SIGNED = int(math.pow(2, 31)) - 1
32+
# Largest addressable position, mirroring Java's RoaringPositionBitmap.MAX_POSITION
33+
# (toPosition(Integer.MAX_VALUE - 1, Integer.MIN_VALUE)): the high 32 bits hold the bitmap
34+
# key and the low 32 bits the position within that bitmap.
35+
MAX_POSITION = ((MAX_JAVA_SIGNED - 1) << 32) | 0x80000000
3236
PROPERTY_REFERENCED_DATA_FILE = "referenced-data-file"
3337
DELETION_VECTOR_MAGIC = b"\xd1\xd3\x39\x64"
3438
# Reserved field id of the row position (_pos) metadata column, referenced by
@@ -48,8 +52,8 @@ def __init__(self, referenced_data_file: str, bitmaps: list[BitMap]) -> None:
4852
def from_positions(cls, referenced_data_file: str, positions: Iterable[int]) -> "DeletionVector":
4953
bitmaps_by_key: dict[int, BitMap] = {}
5054
for position in positions:
51-
if position < 0:
52-
raise ValueError(f"Invalid position: {position}, positions must be non-negative")
55+
if position < 0 or position > MAX_POSITION:
56+
raise ValueError(f"Invalid position: {position}, must be between 0 and {MAX_POSITION}")
5357
bitmaps_by_key.setdefault(position >> 32, BitMap()).add(position & 0xFFFFFFFF)
5458

5559
if not bitmaps_by_key:
@@ -101,6 +105,9 @@ def _serialize_bitmap(bitmaps: list[BitMap]) -> bytes:
101105
if key > MAX_JAVA_SIGNED:
102106
raise ValueError(f"Key {key} is too large, max {MAX_JAVA_SIGNED} to maintain compatibility with Java impl")
103107
out.write(key.to_bytes(4, "little"))
108+
# Run-length encode before serializing so run-heavy vectors (e.g. contiguous
109+
# deletes) stay compact, matching Java's BitmapPositionDeleteIndex.
110+
bitmap.run_optimize()
104111
out.write(bitmap.serialize())
105112
return out.getvalue()
106113

pyiceberg/table/puffin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ def __exit__(
121121
) -> None:
122122
"""Assemble the Puffin file and write it to the output file."""
123123
self.closed = True
124+
# If the with-body raised, skip assembling and writing a half-populated file.
125+
if exc_type is not None:
126+
return
124127

125128
with io.BytesIO() as out:
126129
out.write(MAGIC_BYTES)

tests/table/test_deletion_vector.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from pyiceberg.table.deletion_vector import (
2323
DELETION_VECTOR_MAGIC,
24+
MAX_POSITION,
2425
PROPERTY_REFERENCED_DATA_FILE,
2526
ROW_POSITION_FIELD_ID,
2627
DeletionVector,
@@ -96,10 +97,26 @@ def test_serialize_bitmap_round_trips(positions: list[int]) -> None:
9697

9798

9899
def test_from_positions_rejects_negative() -> None:
99-
with pytest.raises(ValueError, match="Invalid position: -1, positions must be non-negative"):
100+
with pytest.raises(ValueError, match=f"Invalid position: -1, must be between 0 and {MAX_POSITION}"):
100101
DeletionVector.from_positions("file.parquet", [1, -1, 2])
101102

102103

104+
def test_from_positions_rejects_out_of_range() -> None:
105+
too_large = MAX_POSITION + 1
106+
with pytest.raises(ValueError, match=f"Invalid position: {too_large}, must be between 0 and {MAX_POSITION}"):
107+
DeletionVector.from_positions("file.parquet", [1, too_large, 2])
108+
109+
110+
def test_serialize_bitmap_run_optimizes_contiguous_run() -> None:
111+
# A long contiguous run serializes compactly thanks to run-length encoding and still round-trips.
112+
positions = list(range(100_000))
113+
dv = DeletionVector.from_positions("file.parquet", positions)
114+
115+
serialized = DeletionVector._serialize_bitmap(dv._bitmaps)
116+
assert len(serialized) < 1_000 # ~16 KB without run optimization
117+
assert DeletionVector._deserialize_bitmap(serialized) == dv._bitmaps
118+
119+
103120
def test_from_positions_rejects_empty() -> None:
104121
with pytest.raises(ValueError, match="Deletion vector must contain at least one position"):
105122
DeletionVector.from_positions("file.parquet", [])

tests/table/test_puffin.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,20 @@ def test_puffin_writer_empty(tmp_path: Path) -> None:
143143
assert deletion_vectors_from_puffin_file(reader) == []
144144

145145

146+
def test_puffin_writer_does_not_write_on_exception(tmp_path: Path) -> None:
147+
puffin_path = tmp_path / "test.puffin"
148+
output_file = PyArrowFileIO().new_output(str(puffin_path))
149+
150+
with pytest.raises(ValueError, match="boom"):
151+
with PuffinWriter(output_file) as writer:
152+
writer.add_blob(DeletionVector.from_positions("file.parquet", [1]).to_blob())
153+
raise ValueError("boom")
154+
155+
assert writer.closed
156+
# The body raised, so no half-populated file should have been written.
157+
assert not output_file.exists()
158+
159+
146160
def test_add_blob_to_closed_writer_raises(tmp_path: Path) -> None:
147161
output_file = PyArrowFileIO().new_output(str(tmp_path / "test.puffin"))
148162
writer = PuffinWriter(output_file)

0 commit comments

Comments
 (0)