-
Notifications
You must be signed in to change notification settings - Fork 245
fix(table/dv): read deletion vectors by content_offset when the file is not a Puffin container #2071
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix(table/dv): read deletion vectors by content_offset when the file is not a Puffin container #2071
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import ( | |
| "os" | ||
| "path/filepath" | ||
| "strconv" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/apache/iceberg-go" | ||
|
|
@@ -878,7 +879,124 @@ func TestReadDVInvalidPuffin(t *testing.T) { | |
|
|
||
| offset, size := int64(4), int64(16) | ||
| _, err := ReadDV(iceio.LocalFS{}, newDVTestFile(path, 0, &offset, &size)) | ||
| assert.ErrorContains(t, err, "create puffin reader") | ||
| require.ErrorIs(t, err, ErrInvalidDeletionVector) | ||
| assert.ErrorContains(t, err, "not a Puffin container") | ||
| } | ||
|
|
||
| // Why: the bare-blob fallback must stay pinned to inputs that are genuinely | ||
| // not Puffin. A file that starts with the Puffin magic but is truncated or has | ||
| // a broken footer is a damaged Puffin file; routing it to the bare reader would | ||
| // hide a partial upload behind a "format mismatch" diagnostic. | ||
| // Condition: valid header magic, footer missing or corrupt. | ||
| // Assertion: ReadDV fails in the Puffin reader, and the error does not wrap | ||
| // puffin.ErrNotPuffinFile. | ||
| func TestReadDVTruncatedPuffinDoesNotFallBack(t *testing.T) { | ||
| dir := t.TempDir() | ||
| dvBlobBytes := readDVTestData(t, "small-alternating-values-position-index.bin") | ||
| goodPath, meta := writePuffinWithDVBlob(t, dir, dvBlobBytes) | ||
| good, err := os.ReadFile(goodPath) | ||
| require.NoError(t, err) | ||
|
|
||
| cases := map[string][]byte{ | ||
| "magic only": good[:4], | ||
| "truncated footer": good[:len(good)-6], | ||
| "corrupt footer": append(append([]byte{}, good[:len(good)-12]...), []byte("xxxxxxxxxxxx")...), | ||
| } | ||
| for name, raw := range cases { | ||
| t.Run(name, func(t *testing.T) { | ||
| path := filepath.Join(dir, strings.ReplaceAll(name, " ", "_")+".puffin") | ||
| require.NoError(t, os.WriteFile(path, raw, 0o644)) | ||
| offset, size := meta.Offset, meta.Length | ||
| _, err := ReadDV(iceio.LocalFS{}, newDVTestFile(path, 5, &offset, &size)) | ||
| require.Error(t, err) | ||
| assert.False(t, errors.Is(err, puffin.ErrNotPuffinFile), "damaged Puffin file must not be treated as bare: %v", err) | ||
| assert.ErrorContains(t, err, "create puffin reader") | ||
| assert.NotContains(t, err.Error(), "not a Puffin container") | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // Why: Databricks writes deletion vectors for IcebergCompatV3 (UniForm) tables | ||
| // as a Delta deletion_vector_*.bin — a one-byte version prefix followed by | ||
| // deletion-vector-v1 blobs, with no Puffin header or footer — and the manifest | ||
| // addresses the blob with content_offset / content_size_in_bytes. The Java | ||
| // reference reader reads these directly; so must we. | ||
| // Condition: the DV file has no Puffin container but the manifest range holds a valid blob. | ||
| // Assertion: ReadDV/ReadDVs decode the blob and validate cardinality against record_count. | ||
| func TestReadDVBareBlobWithoutPuffinContainer(t *testing.T) { | ||
| dir := t.TempDir() | ||
| path := filepath.Join(dir, "deletion_vector_0001.bin") | ||
|
|
||
| first := NewRoaringPositionBitmap() | ||
| first.Set(1) | ||
| first.Set(9) | ||
| firstData, err := SerializeDV(first) | ||
| require.NoError(t, err) | ||
| second := NewRoaringPositionBitmap() | ||
| second.Set(7) | ||
| secondData, err := SerializeDV(second) | ||
| require.NoError(t, err) | ||
|
|
||
| raw := append([]byte{0x01}, firstData...) // Delta DV file version byte, then blobs back to back | ||
| secondOffset := int64(len(raw)) | ||
| raw = append(raw, secondData...) | ||
| require.NoError(t, os.WriteFile(path, raw, 0o644)) | ||
|
|
||
| firstOffset, firstSize := int64(1), int64(len(firstData)) | ||
| bm, err := ReadDV(iceio.LocalFS{}, newDVTestFile(path, 2, &firstOffset, &firstSize)) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, int64(2), bm.Cardinality()) | ||
| assert.True(t, bm.Contains(1)) | ||
| assert.True(t, bm.Contains(9)) | ||
|
|
||
| secondSize := int64(len(secondData)) | ||
| files := []iceberg.DataFile{ | ||
| newDVTestFile(path, 2, &firstOffset, &firstSize), | ||
| newDVTestFile(path, 1, &secondOffset, &secondSize), | ||
| } | ||
| bitmaps, err := ReadDVs(iceio.LocalFS{}, files) | ||
| require.NoError(t, err) | ||
| require.Len(t, bitmaps, 2) | ||
| assert.Equal(t, int64(2), bitmaps[0].Cardinality()) | ||
| assert.True(t, bitmaps[1].Contains(7)) | ||
|
|
||
| t.Run("cardinality still validated against record_count", func(t *testing.T) { | ||
| _, err := ReadDV(iceio.LocalFS{}, newDVTestFile(path, 3, &firstOffset, &firstSize)) | ||
| require.ErrorIs(t, err, ErrInvalidDeletionVector) | ||
| assert.ErrorContains(t, err, "cardinality mismatch") | ||
| }) | ||
|
|
||
| t.Run("range beyond file", func(t *testing.T) { | ||
| badOffset, badSize := int64(1), int64(len(raw)+8) | ||
| _, err := ReadDV(iceio.LocalFS{}, newDVTestFile(path, 2, &badOffset, &badSize)) | ||
| require.ErrorIs(t, err, ErrInvalidDeletionVector) | ||
| assert.ErrorContains(t, err, "direct read") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The subtests cover ReadAt failing (range beyond file) and post-deserialize cardinality mismatch, but not the in-between: ReadAt succeeds and
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 12b915c: added the "corrupt blob CRC" subtest in |
||
| }) | ||
|
|
||
| t.Run("corrupt blob CRC", func(t *testing.T) { | ||
| bad := append([]byte{}, raw...) | ||
| for i := int(firstOffset+firstSize) - 4; i < int(firstOffset+firstSize); i++ { | ||
| bad[i] ^= 0xFF | ||
| } | ||
| badPath := filepath.Join(dir, "deletion_vector_crc.bin") | ||
| require.NoError(t, os.WriteFile(badPath, bad, 0o644)) | ||
| _, err := ReadDV(iceio.LocalFS{}, newDVTestFile(badPath, 2, &firstOffset, &firstSize)) | ||
| require.ErrorIs(t, err, ErrInvalidDeletionVector) | ||
| assert.ErrorContains(t, err, "blob at offset") | ||
| assert.ErrorContains(t, err, "CRC mismatch") | ||
| }) | ||
|
|
||
| t.Run("blobs read in offset order, results in input order", func(t *testing.T) { | ||
| reversed := []iceberg.DataFile{ | ||
| newDVTestFile(path, 1, &secondOffset, &secondSize), | ||
| newDVTestFile(path, 2, &firstOffset, &firstSize), | ||
| } | ||
| bitmaps, err := ReadDVs(iceio.LocalFS{}, reversed) | ||
| require.NoError(t, err) | ||
| require.Len(t, bitmaps, 2) | ||
| assert.True(t, bitmaps[0].Contains(7)) | ||
| assert.Equal(t, int64(2), bitmaps[1].Cardinality()) | ||
| }) | ||
| } | ||
|
|
||
| // Why: offset, size, and cardinality cannot prove that the selected Puffin blob | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TestReadDVInvalidPuffinnow feeds a 17-byte file, which is the too-small to bare-path case, so nothing here pins the tighter invariant: a file that starts with real Puffin magic but has a broken/truncated footer should fail inreadFooterwith an error that does NOT wrapErrNotPuffinFile, i.e. the bare fallback must not activate. I'd add that case so a future change that accidentally wraps footer errors inErrNotPuffinFilecan't silently route a corrupt Puffin file to the bare reader.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 12b915c:
TestReadDVTruncatedPuffinDoesNotFallBackcovers magic-only, truncated-footer and corrupt-footer files — each fails in the Puffin reader,errors.Is(err, puffin.ErrNotPuffinFile)is false, and the error does not carry "not a Puffin container".