Add PuffinWriter for writing deletion vectors#3474
Conversation
| return {path: _bitmaps_to_chunked_array(bitmaps) for path, bitmaps in self._deletion_vectors.items()} | ||
|
|
||
|
|
||
| class PuffinWriter: |
There was a problem hiding this comment.
This name looks too generic. We could consider renaming it to DeletionVectorWriter or a similar name. I've opened #3491 to extract DV-specific logic from puffin.py.
There was a problem hiding this comment.
Sounds good to me. I'll wait for #3491 to land and then rebase this on top of it, renaming the writer to DeletionVectorWriter as part of that. Thanks both!
There was a problem hiding this comment.
sungwy
left a comment
There was a problem hiding this comment.
Thanks for working on this @moomindani - I added some initial review comments
| return {path: _bitmaps_to_chunked_array(bitmaps) for path, bitmaps in self._deletion_vectors.items()} | ||
|
|
||
|
|
||
| class PuffinWriter: |
Reworked onto #3491Now that #3491 has merged, I've rebased this onto it (via a merge of
Notable changes in context
How prior review feedback is addressed
Wiring this into the merge-on-read branch of |
|
|
||
| puffin_bytes = out.getvalue() | ||
|
|
||
| with self._output_file.create(overwrite=True) as output_stream: |
There was a problem hiding this comment.
Why is overwrite=True hardcoded here? For idempotency? If a caller accidentally passes an existing file path, this silently replaces it.
There was a problem hiding this comment.
Yes — it's for idempotency. This matches the other write-once writers in PyIceberg: the data file writer (pyarrow.py) and the manifest writer (avro/file.py) both hardcode overwrite=True. Puffin DV files are written to fresh, UUID-based locations (like data/manifest files), so a collision with a different file isn't expected; the realistic case where the path already exists is a retry writing the same file, and overwrite=True keeps that idempotent instead of failing. Only ToOutputFile.table_metadata parameterizes overwrite, since metadata writes can legitimately target an existing path.
That said, if you can think of a case where silently overwriting would be a problem, I'm happy to make it a constructor argument.
|
Could you rebase on main to resolve conflicts? |
…inWriter Following the design agreed in apache#3491, split the write path into: - DeletionVector serialization (domain): from_positions(), _serialize_bitmap() and to_blob() as the write-side counterparts of _deserialize_bitmap() and to_vector(), reusing MAX_JAVA_SIGNED / PROPERTY_REFERENCED_DATA_FILE. - A generic, blob-agnostic PuffinWriter (format) that assembles a Puffin file from PuffinBlob payloads, mirroring PuffinFile on the read side and scaling to future blob types (e.g. apache-datasketches-theta-v1). PuffinWriter is a context manager (consistent with ManifestWriter); the file size is available via len(output_file) after exit. The created-by default uses pyiceberg.__version__. Co-authored-by: Isaac
… 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
Restructure test_puffin_writer_does_not_write_on_exception to use try/except instead of nesting an unconditional raise inside pytest.raises, which mypy's warn-unreachable flagged as unreachable. Co-authored-by: Isaac
b26e7ed to
5fdd51f
Compare
|
Done — rebased onto the latest |
|
Can you change the PR description? We're not using the Spark test anymore (waiting until a later PR), so we shouldn't mention that the test exists for future PR readers. |
| with io.BytesIO() as out: | ||
| out.write(len(non_empty).to_bytes(8, "little")) | ||
| for key, bitmap in non_empty: | ||
| if key > MAX_JAVA_SIGNED: |
There was a problem hiding this comment.
I think we've got an off-by-one error here.
Here's Java
Java fails if key > (2^31 - 1) - 1
We fail if key > (2^31 ) - 1
This is a weird edge case and definitely an issue in Java, but probably not worth fixing over there.
There was a problem hiding this comment.
You're right, thanks — Java's readKey rejects key > Integer.MAX_VALUE - 1 while this check allowed one more key, so we could write a DV that Java cannot read. Fixed the bound to match Java. Since the key is the list index in _serialize_bitmap, the boundary isn't practically constructible in a test (it would need a 2^31-element list); the existing MAX_POSITION validation in from_positions keeps user input below the bound, so this check stays as defense in depth.
rambleraptor
left a comment
There was a problem hiding this comment.
Broadly this looks great. I saw a quick off-by-one error, but that's fine as-is. I do want to verify that we meant to get rid of the Spark test. I know that was a blocker in my original PR and I want to make sure that we get that test in at some point.
a4b4c67 to
5fdd51f
Compare
|
Thanks for the review! Updated the description to remove the Spark interop test references. |
Java's RoaringPositionBitmap.readKey rejects keys above Integer.MAX_VALUE - 1, while the serializer allowed one more key, so PyIceberg could write a deletion vector Java cannot read.
Part of #2261. Continues #2822.
Rationale for this change
This adds a
PuffinWriterfor writing Puffin files containingdeletion-vector-v1blobs — the first building block for deletion-vector write support in PyIceberg (tracking issue #2261).It revives #2822 by @rambleraptor, which was auto-closed by the stale bot rather than on merit. The original work — including all review feedback already addressed there (@ebyhr, @geruh) — is preserved commit-for-commit.
On top of that, this PR adds unit tests for two agreed review items that were not yet asserted by any test:
fieldsvalue[2147483645](JavaMetadataColumns.ROW_POSITION, INT_MAX - 2), required for Java/Spark interoperability; andPuffinFilereader skips, so the round-trip tests did not previously exercise it.As in the original PR, this is intentionally scoped to the writer + tests so we can agree on the write semantics before wiring it into the delete/manifest writers and the merge-on-read path. Per the original review discussion, the writer expects the caller to provide one merged deletion vector per data file.
Are these changes tested?
Yes: unit tests for round-trip write/read, the single-blob (1:1) behavior, the DV field id, byte-level blob framing, and empty files (
tests/table/test_puffin.py).Are there any user-facing changes?
No.
PuffinWriteris a new internal building block and is not yet wired into any public write path.