Skip to content

Add PuffinWriter for writing deletion vectors#3474

Open
moomindani wants to merge 4 commits into
apache:mainfrom
moomindani:moomindani/dv-write-revival
Open

Add PuffinWriter for writing deletion vectors#3474
moomindani wants to merge 4 commits into
apache:mainfrom
moomindani:moomindani/dv-write-revival

Conversation

@moomindani

@moomindani moomindani commented Jun 9, 2026

Copy link
Copy Markdown

Part of #2261. Continues #2822.

Rationale for this change

This adds a PuffinWriter for writing Puffin files containing deletion-vector-v1 blobs — 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:

  • the blob fields value [2147483645] (Java MetadataColumns.ROW_POSITION, INT_MAX - 2), required for Java/Spark interoperability; and
  • the deletion-vector blob framing at the byte level (length prefix, DV magic, CRC-32 over magic + vector), which the PuffinFile reader 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. PuffinWriter is a new internal building block and is not yet wired into any public write path.

Comment thread pyiceberg/table/puffin.py Outdated
Comment thread pyiceberg/table/puffin.py Outdated
Comment thread tests/integration/test_puffin_spark_interop.py Outdated
Comment thread pyiceberg/table/puffin.py Outdated
Comment thread tests/integration/test_puffin_spark_interop.py Outdated
Comment thread pyiceberg/table/puffin.py Outdated
Comment thread pyiceberg/table/puffin.py Outdated
Comment thread pyiceberg/table/puffin.py
return {path: _bitmaps_to_chunked_array(bitmaps) for path, bitmaps in self._deletion_vectors.items()}


class PuffinWriter:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to @ebyhr's point

Once #3491 lands I think it would be worth rebasing this implementation on top of it. WDYT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up here: after the discussion in #3491 we decided to keep PuffinWriter as a generic, format-level writer and put the DV serialization on DeletionVector (to_blob()), rather than renaming. I've reworked this PR onto #3491 accordingly — details in the summary comment above.

@sungwy sungwy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this @moomindani - I added some initial review comments

Comment thread pyiceberg/table/puffin.py Outdated
Comment thread pyiceberg/table/puffin.py
return {path: _bitmaps_to_chunked_array(bitmaps) for path, bitmaps in self._deletion_vectors.items()}


class PuffinWriter:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to @ebyhr's point

Once #3491 lands I think it would be worth rebasing this implementation on top of it. WDYT?

@moomindani

Copy link
Copy Markdown
Author

Reworked onto #3491

Now that #3491 has merged, I've rebased this onto it (via a merge of main plus a rework commit, so the earlier history is preserved). The write path is now split along the same format/domain boundary #3491 established on the read side:

  • DeletionVector (domain) gains the write-side counterparts of the read path: from_positions(), _serialize_bitmap() (counterpart of _deserialize_bitmap()), and to_blob() (counterpart of to_vector()). It reuses MAX_JAVA_SIGNED / PROPERTY_REFERENCED_DATA_FILE from Extract DeletionVector logic from PuffinFile #3491.
  • PuffinWriter (format) stays in puffin.py as a generic, blob-agnostic writer 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).

Notable changes in context

  • Naming: I'd earlier said I'd rename to DeletionVectorWriter, but per the follow-up discussion in Extract DeletionVector logic from PuffinFile #3491 we settled on keeping PuffinWriter generic and putting the DV serialization on DeletionVector instead. That's what's implemented here.
  • Interface: PuffinWriter is now a context manager (consistent with ManifestWriter); the file size is available via len(output_file) after the with block, replacing the earlier finish() -> int.
  • created-by: defaults to PyIceberg version {__version__} via from pyiceberg import __version__.

How prior review feedback is addressed

Feedback Source Status
created_by default = PyIceberg version {version} @ebyhr Done
Use from pyiceberg import __version__ (not importlib.metadata) @sungwy Done
Accept an OutputFile and write to it (Java PuffinWriter shape) @ebyhr Done
Fix misleading docstring ("writer doesn't write a file") @ebyhr Done
Avoid numbered prefixes / excessive single-line comments @ebyhr Done
Extract the Spark interop test to its own PR @ebyhr Done (#3476)
DV serialization on DeletionVector (counterpart to to_vector) #3491 Done (to_blob)
Keep a generic, format-level PuffinWriter in puffin.py #3491 Done
Reuse MAX_JAVA_SIGNED / PROPERTY_REFERENCED_DATA_FILE #3491 Done
Lock in DV field id and blob framing with tests review Done (test_to_blob_payload_layout)
Tests for sparse bitmap keys and the Java key-range limit review Done

Wiring this into the merge-on-read branch of Transaction.delete() for v3 tables (toward #1078), where the Spark-reads-PyIceberg interop test will live, remains a follow-up.

Comment thread pyiceberg/table/puffin.py
Comment thread pyiceberg/table/deletion_vector.py
Comment thread pyiceberg/table/deletion_vector.py Outdated
Comment thread pyiceberg/table/puffin.py

puffin_bytes = out.getvalue()

with self._output_file.create(overwrite=True) as output_stream:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is overwrite=True hardcoded here? For idempotency? If a caller accidentally passes an existing file path, this silently replaces it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ebyhr

ebyhr commented Jun 30, 2026

Copy link
Copy Markdown
Member

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
@moomindani moomindani force-pushed the moomindani/dv-write-revival branch from b26e7ed to 5fdd51f Compare June 30, 2026 05:03
@moomindani

Copy link
Copy Markdown
Author

Done — rebased onto the latest main and resolved the conflicts. The branch now sits on top of main with the deletion-vector framing reconciled against the recent reader changes (_file_bytes absolute offsets and _extract_vector_payload).

@moomindani

Copy link
Copy Markdown
Author

@ebyhr @sungwy This has been rebased onto the latest main and CI is green. Could you take another look when you have a chance?

@rambleraptor

Copy link
Copy Markdown
Collaborator

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.

Comment thread pyiceberg/table/deletion_vector.py Outdated
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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 rambleraptor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@moomindani moomindani force-pushed the moomindani/dv-write-revival branch from a4b4c67 to 5fdd51f Compare July 9, 2026 00:01
@moomindani

moomindani commented Jul 9, 2026

Copy link
Copy Markdown
Author

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants