feat(iceberg): experimental deletion vector delete mode - #1119
Draft
shubham19may wants to merge 2 commits into
Draft
feat(iceberg): experimental deletion vector delete mode#1119shubham19may wants to merge 2 commits into
shubham19may wants to merge 2 commits into
Conversation
Reference implementation for writing Iceberg v3 deletion vectors, sitting on top of the positional delete work. Not intended to ship as is: v3 is a one-way format change per table, and engine support for reading vectors is narrower than for positional deletes, which is the compatibility this whole line of work exists to get. Delete representation is now named rather than boolean. delete_mode travels on the handshake as "eq", "pos" or "dv", and use_positional_deletes stays behind it so a server built before this still behaves. The migration request carries target_mode the same way, so a table leaving equality deletes lands directly on whichever representation the job now writes. PositionalDeltaWriter no longer knows how a delete is encoded. Its delete side is a PositionalDeleteSink with two implementations: - PositionalFiles keeps the per-partition sorting writers and the file-scoped granularity that lets Iceberg match deletes to data files by path. - DeletionVectors wraps BaseDVFileWriter. Iceberg permits one vector per data file, so a commit deleting further rows from a file must publish the union of old and new positions and retire the vector it replaces. PreviousDeleteLoader supplies the existing positions, reading either representation, and the retired files are reported so the commit can remove them. Threading the sink through also removed the per-row PartitionKey copy: a partition's data writer and partition value are now held together, so recording where a row landed allocates one small object rather than a copied key. Tables are created at the version their mode needs, and an existing v2 table is upgraded when a job switches to vectors. The row index scanner now resolves deleted positions through Iceberg's delete loader instead of reading positional delete parquet directly, so it sees vectors, delete files, or a table part way between them. EqualityDeleteMigrator takes a target mode and writes vectors or delete files from the same plan, still in one atomic rewrite. Verified against a real table on a local Hadoop catalog, with every positional scenario re-run in vector mode: unpartitioned and partitioned fan-out, a key updated repeatedly within one batch and across batches, a 10k-row batch over repeated keys, file rolling, and partition-changing updates. Vector-specific coverage checks that a second commit merges into the file's existing vector rather than resurrecting what it already deleted, that a data file ends up with exactly one vector across repeated commits, and that equality deletes migrate into vectors with none surviving. End to end on Postgres to Iceberg with --delete-type dv: 70k-row backfill across four tables, a 25k-record CDC round covering 12k repeated-key updates, partition-changing updates, soft deletes and cursor-based incremental, and a job switched from eq to dv mid-life, which upgraded the table to v3 and left only Puffin deletes in the current snapshot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tests run against a table on a local Hadoop catalog rather than asserting on writer internals: rows are committed through RowDelta and read back with a normal scan, so Iceberg decides whether each delete applied. That is the only way to catch the cases where a delete is written correctly and still resolves to nothing. Every positional scenario is parameterised over POSITION and DELETION_VECTOR, so both representations answer the same questions: unpartitioned and partitioned fan-out, a key updated repeatedly within one batch and across batches, a 10k-row batch over 1000 repeated keys, file rolling at a 4 KB target, and partition-changing updates. Vector-specific cases cover what makes vectors different from delete files: a later commit must merge into the vector already on the data file rather than resurrect what it deleted, a data file must end up with exactly one vector across repeated commits, and equality deletes must migrate into vectors with none surviving. Two of these encode findings that were wrong on the first pass and are worth keeping honest: - partitionChangingUpdateSupersedesTheOldRow asserts the delete is filed under the new partition and is still file-scoped, so Iceberg matches it to the old row's data file by path. Tests build their appender through IcebergUtil.getTableAppender, because a bare GenericAppenderFactory truncates the file_path bounds and quietly changes that matching. - validationRefusesDeletesWhoseDataFileWasRewritten commits a rewrite and asserts ValidationException, covering the window assertRowIndexCurrent cannot: a concurrent rewrite landing after the pre-check. The commit helper sets validateFromSnapshot the way production does. Without it Iceberg scans from the first snapshot and reads the previous commit's own vector as a concurrent addition. Added with git add -f: .gitignore carries a bare "test" pattern that matches any directory of that name, so the whole Java test tree is ignored repo-wide. Narrowing that pattern is left alone here to avoid unignoring anything else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacks on #1118. Reference implementation, not intended to merge as is — see Risks.
Writes Iceberg v3 deletion vectors as a third delete mode, alongside equality and positional deletes. Built now, while the context from the positional work is fresh, so the eventual implementation has a worked example and a test matrix to start from rather than a blank page.
Why it is cheap
Everything needed is already on the classpath — no new dependency, no Puffin plumbing written by hand:
BaseDVFileWriter—delete(path, pos, spec, partition), the same shape the existing call already hadBaseDeleteLoader.loadPositionDeletes(files, path)(iceberg-data, already a dependency)ContentFileUtil.isDVDeleteWriteResult.rewrittenDeleteFiles()What changed
Delete representation is named, not boolean.
delete_mode("eq"/"pos"/"dv") travels on the handshake, withuse_positional_deleteskept behind it so a server built before this still behaves.MigrateEqualityDeletesRequestcarriestarget_modethe same way, so a table leaving equality deletes lands directly on whichever representation the job now writes.Generated stubs were regenerated with protoc 23.4 (taken from the Maven repo), not the system protoc 35.1 — 35.1 emits protobuf-java 4.x APIs (
RuntimeVersion) that the pinned 3.25.5 runtime does not have. Only a message field changed, so the gRPC service stubs are untouched and noprotoc-gen-grpc-javawas needed.PositionalDeltaWriterno longer knows how a delete is encoded. Its delete side is aPositionalDeleteSinkwith two implementations:PositionalFiles— the per-partition sorting writers and the file-scoped granularity that lets Iceberg match deletes to data files by path.DeletionVectors— wrapsBaseDVFileWriter. Iceberg permits one vector per data file, so a commit deleting further rows from a file must publish the union of old and new positions and retire the vector it replaces.PreviousDeleteLoadersupplies the existing positions — reading either representation, so a half-migrated table is handled — and the retired files are reported so the commit canremoveDeletesthem.Threading the sink through also removed the per-row
PartitionKeycopy: a partition's data writer and partition value are now held together, so recording where a row landed allocates one small object rather than a copied key.Format versions. Tables are created at the version their mode needs (v2 for eq/pos, v3 for dv), and an existing v2 table is upgraded in place when a job switches to vectors.
Row index scanner resolves deleted positions through Iceberg's delete loader instead of reading positional delete parquet directly, so it sees vectors, delete files, or a table part way between them. This is a net simplification — the hand-rolled
BitSetdecoding is gone.EqualityDeleteMigratortakes a target mode and writes vectors or delete files from the same plan, still in one atomicRewriteFilesso readers never observe the rows as undeleted.eq → posandeq → dvare both supported;pos → dvis deliberately out of scope for now.Testing
Unit — 36 tests
Every positional scenario from #1118 is now a
@ParameterizedTestover bothPOSITIONandDELETION_VECTOR, against a real table on a local Hadoop catalog, committed throughRowDeltaand read back with a normal scan:Vector-specific coverage on top:
deletionVectorMergesWithTheOneAlreadyOnTheDataFile— a second commit deleting a different row of the same data file must publish the union; asserts the earlier deletion is not resurrected and that the previous vector is reported as retiredaDataFileEndsUpWithExactlyOneDeletionVector— three successive commits each delete another row of the same seed file; asserts exactly one vector survives, and that every commit after the first retires its predecessorequalityDeletesMigrateIntoDeletionVectors— seeds real equality deletes, migrates, then asserts none survive and every remaining delete is a vectorEnd to end — Postgres → Iceberg,
--delete-type dvbig_table50000 / 50000 distinct, 0 stale intermediates, 3000 final versionschunk_table20000 / 20000 distinct, 50 rows moved partitions cleanlyPUFFIN, content=1,referenced_data_fileset — real vectorspart_table6 / 6, including a partition-changing update and a soft deleteeq → dvmid-life switcheqmode, then run with--delete-type dv: table upgraded v2 → v3, 1 equality delete file rewritten into 2 vectors, current snapshot left with only PUFFIN deletes, data correctRisks — why this should not ship yet
Engine support is the deciding factor, and it is a product question, not an engineering one. This whole line of work exists for Snowflake compatibility. Positional deletes are v2 and universally read. Vectors are v3: Spark 3.5 with Iceberg 1.9+ reads them, but Snowflake's v3 support is far newer and may not cover vectors at all. Shipping this could narrow interop rather than widen it. Worth confirming against Snowflake's current v3 support before investing further.
FORMAT_VERSIONis a one-way door. v2 → v3 upgrades in place and cannot be reverted; any reader that does not speak v3 loses the table. Every existing olake table is v2. The upgrade is logged at WARN, but it is silent from the user's point of view.Vectors depend on the commit validation from #1118. Committing a vector without
validateFromSnapshotmakes Iceberg scan from the first snapshot and read your own previous vector as "concurrently added" (MergingSnapshotProducer.validateAddedDVs). Production sets it viaapplyRowIndexValidations; my test harness initially did not, which is how I found this. Vector mode is not viable without that change.PreviousDeleteLoaderplans the table once per writer. Correct, and only files actually deleted from are loaded — but on a table with many delete files this is a real cost at writer creation, and it has not been profiled at scale.pos → dvmigration is not implemented. A table already carrying positional delete files will not be converted.PreviousDeleteLoaderreads both forms, so the two can coexist, but that path is untested.Note on the test tree
The tests live in
src/test/java/.../PositionalDeltaWriterTest.javaand had to be added withgit add -f:.gitignorecarries a baretestpattern that matches any directory of that name, includingsrc/test, so the whole Java test tree is ignored repo-wide. That is also why this project had surefire configured but no tests until now. The pattern is left alone here — narrowing it could unignore other paths — but it is worth fixing separately.pom.xmlgains junit-jupiter at test scope; without it the branch does not compile.Since every positional scenario is parameterised over both modes, this file also covers the writer from #1118. Running
mvn teston this branch exercises both.