This is an automated email from the ASF dual-hosted git repository.

laskoviymishka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 3a0d02b71 fix(table/dv): read deletion vectors by content_offset when 
the file is not a Puffin container (#2071)
3a0d02b71 is described below

commit 3a0d02b71989cf044208fdd1473feac29358e594
Author: Sevban Bayrak <[email protected]>
AuthorDate: Thu Oct 1 11:08:42 2026 +0300

    fix(table/dv): read deletion vectors by content_offset when the file is not 
a Puffin container (#2071)
    
    * fix(table/dv): read deletion vectors by content_offset when the file is 
not a Puffin container
    
    Databricks writes deletion vectors for IcebergCompatV3 (UniForm) tables as a
    Delta deletion_vector_*.bin file: a one-byte version prefix followed by
    deletion-vector-v1 blobs, with no Puffin header or footer. The manifest 
entry
    is well-formed (file_format=PUFFIN, referenced_data_file, content_offset,
    content_size_in_bytes point at a valid blob), and the Java reference reader
    (BaseDeleteLoader.readDV) reads such files because it never consults the
    Puffin footer: it reads content_size_in_bytes bytes at content_offset.
    
    ReadDV/ReadDVs opened the file with puffin.NewReader first and failed with
    "puffin: invalid header magic" on these tables.
    
    - puffin: NewReader wraps the header-magic and too-small errors in a new
      sentinel, ErrNotPuffinFile, so callers can detect "no Puffin container".
    - table/dv: when the DV file is not a Puffin container, fall back to reading
      the blobs directly at content_offset (Java parity). The blob's length,
      magic and CRC-32 are still verified by DeserializeDV and the decoded
      cardinality is validated against the manifest record_count; footer-only
      checks (blob type, referenced-data-file property, cardinality property)
      are skipped with a slog warning. Real Puffin files keep the existing
      strict path unchanged.
    
    Verified against a Databricks Unity Catalog IcebergCompatV3 table read 
through
    the UC Iceberg REST catalog with vended credentials: 968,980 rows after
    1,020 deletes + 1,020 updates, matching the SQL result; copy-on-write and
    non-DV tables unaffected.
    
    Fixes #2070
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Claude-Session: https://claude.ai/code/session_01MqCR5xLuYcb7Baw6Xbj4be
    
    * fix(table/dv): scope ErrNotPuffinFile to magic-first, reuse the open 
handle for bare DV reads
    
    Review follow-up:
    
    - puffin.NewReader checks the header magic before the minimum-size guard.
      ErrNotPuffinFile is returned only when the file is too short to hold the
      magic or the leading bytes are not PFA1; a file that starts with the magic
      but is truncated keeps the plain "puffin: file too small" error, so a
      partial upload of a real Puffin file is never routed to the bare-blob 
path.
    - openDVReader returns the still-open file (nil reader) on ErrNotPuffinFile;
      readBareDVs reads from that handle instead of reopening the path.
    - readBareDVs no longer re-runs validateDVFile (both callers validate before
      dispatch; documented), reads blobs in content_offset order and restores
      input order, and logs the "not a Puffin container" warning once per file
      path, after the first blob decoded.
    - Tests: truncated / corrupt-footer Puffin files fail without wrapping
      ErrNotPuffinFile; bare blob with corrupt CRC surfaces the "blob at offset"
      wrap; reversed-offset input keeps output order; puffin size test uses a
      valid-magic short file plus a too-short-for-magic case.
    
    Re-verified against a fresh Databricks IcebergCompatV3 table with two
    deletion vectors: 999,000 rows / 1,000 updated markers, matching SQL.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Claude-Session: https://claude.ai/code/session_01MqCR5xLuYcb7Baw6Xbj4be
    
    ---------
    
    Co-authored-by: Claude Fable 5.1 <[email protected]>
---
 puffin/puffin_reader.go          |  30 +++++++---
 puffin/puffin_test.go            |   7 ++-
 table/dv/deletion_vector.go      |  81 ++++++++++++++++++++++++++
 table/dv/deletion_vector_test.go | 120 ++++++++++++++++++++++++++++++++++++++-
 4 files changed, 227 insertions(+), 11 deletions(-)

diff --git a/puffin/puffin_reader.go b/puffin/puffin_reader.go
index b858a3f78..c7b3b58ce 100644
--- a/puffin/puffin_reader.go
+++ b/puffin/puffin_reader.go
@@ -33,6 +33,15 @@ import (
        "github.com/pierrec/lz4/v4"
 )
 
+// ErrNotPuffinFile is returned by NewReader when the input does not carry a
+// Puffin container: the file is too short to hold the header magic, or its
+// leading bytes are not the Puffin magic. A file that starts with the magic
+// but is truncated or has a broken footer is a damaged Puffin file and does
+// NOT wrap this error. Callers that can still consume the payload by other
+// means (e.g. a deletion vector addressed by content_offset) may test for it
+// with errors.Is.
+var ErrNotPuffinFile = errors.New("puffin: not a puffin file")
+
 // ReaderAtSeeker combines io.ReaderAt and io.Seeker for reading Puffin files.
 // This interface is implemented by *os.File, *bytes.Reader, and similar types.
 type ReaderAtSeeker interface {
@@ -105,20 +114,25 @@ func NewReader(r ReaderAtSeeker, opts ...ReaderOption) 
(*Reader, error) {
                return nil, fmt.Errorf("puffin: detect file size: %w", err)
        }
 
-       // Minimum size: header magic + footer magic + footer trailer
-       // [Magic] + zero for blob + [Magic] + [FooterPayloadSize (assuming 
~0)] + [Flags] + [Magic]
-       minSize := int64(MagicSize + MagicSize + footerTrailerSize)
-       if size < minSize {
-               return nil, fmt.Errorf("puffin: file too small (%d bytes, 
minimum %d)", size, minSize)
+       // Validate header magic first: only a file that cannot show the Puffin
+       // magic is "not a Puffin file". A file that starts with the magic but 
is
+       // truncated is a damaged Puffin file and must not be mistaken for one.
+       if size < int64(MagicSize) {
+               return nil, fmt.Errorf("%w: file too small to hold header magic 
(%d bytes)", ErrNotPuffinFile, size)
        }
-
-       // Validate header magic
        var headerMagic [MagicSize]byte
        if _, err := r.ReadAt(headerMagic[:], 0); err != nil {
                return nil, fmt.Errorf("puffin: read header magic: %w", err)
        }
        if !bytes.Equal(headerMagic[:], magic[:]) {
-               return nil, errors.New("puffin: invalid header magic")
+               return nil, fmt.Errorf("%w: invalid header magic", 
ErrNotPuffinFile)
+       }
+
+       // Minimum size: header magic + footer magic + footer trailer
+       // [Magic] + zero for blob + [Magic] + [FooterPayloadSize (assuming 
~0)] + [Flags] + [Magic]
+       minSize := int64(MagicSize + MagicSize + footerTrailerSize)
+       if size < minSize {
+               return nil, fmt.Errorf("puffin: file too small (%d bytes, 
minimum %d)", size, minSize)
        }
 
        pr := &Reader{
diff --git a/puffin/puffin_test.go b/puffin/puffin_test.go
index 0132e1f6a..907c8212d 100644
--- a/puffin/puffin_test.go
+++ b/puffin/puffin_test.go
@@ -653,8 +653,11 @@ func TestReaderInvalidFile(t *testing.T) {
                data    func() []byte
                wantErr string
        }{
-               // file too small: Minimum valid puffin file has header magic + 
footer, rejects truncated files.
-               {"file too small", func() []byte { return []byte("tiny") }, 
"too small"},
+               // file too small: Minimum valid puffin file has header magic + 
footer, rejects truncated files
+               // (magic is valid here, so this is a damaged Puffin file, not 
ErrNotPuffinFile).
+               {"file too small", func() []byte { return []byte("PFA1tiny") }, 
"too small"},
+               // too small to hold the magic: cannot be identified as Puffin 
at all.
+               {"too small for magic", func() []byte { return []byte("ti") }, 
"too small to hold header magic"},
                // invalid header magic: First 4 bytes must be 'PFA1' to 
identify puffin format.
                {"invalid header magic", func() []byte {
                        d := validFile()
diff --git a/table/dv/deletion_vector.go b/table/dv/deletion_vector.go
index abaa707a6..206b91338 100644
--- a/table/dv/deletion_vector.go
+++ b/table/dv/deletion_vector.go
@@ -28,6 +28,7 @@ import (
        "math"
        "slices"
        "strconv"
+       "sync"
 
        "github.com/apache/iceberg-go"
        iceberginternal "github.com/apache/iceberg-go/internal"
@@ -209,6 +210,14 @@ func ReadDV(fs iceio.IO, dvFile iceberg.DataFile) 
(*RoaringPositionBitmap, error
                return nil, err
        }
        defer f.Close()
+       if reader == nil { // not a Puffin container: read the blob directly at 
content_offset
+               bitmaps, err := readBareDVs(f, []iceberg.DataFile{dvFile})
+               if err != nil {
+                       return nil, err
+               }
+
+               return bitmaps[0], nil
+       }
 
        _, _, manifestReferencedDataFile, contentOffset, contentSize := 
iceberginternal.BorrowedDataFilePointers(dvFile)
        offset, size := *contentOffset, *contentSize
@@ -252,6 +261,9 @@ func ReadDVs(fs iceio.IO, dvFiles []iceberg.DataFile) 
([]*RoaringPositionBitmap,
                return nil, err
        }
        defer f.Close()
+       if reader == nil { // not a Puffin container: read the blobs directly 
at content_offset
+               return readBareDVs(f, dvFiles)
+       }
 
        blobsByOffset := indexBlobMetadataByOffset(reader.Blobs())
        type dvBlobRead struct {
@@ -327,6 +339,68 @@ func ReadDVs(fs iceio.IO, dvFiles []iceberg.DataFile) 
([]*RoaringPositionBitmap,
        return bitmaps, nil
 }
 
+// bareDVWarned records the bare (non-Puffin) DV files already reported, so a
+// snapshot with thousands of such files logs each path once.
+var bareDVWarned sync.Map
+
+// readBareDVs reads deletion vectors from an open file that is not a Puffin
+// container: the deletion-vector-v1 blobs are addressed directly by the
+// manifest's content_offset / content_size_in_bytes, exactly as the Java
+// reference reader (BaseDeleteLoader.readDV) does, which never consults the
+// Puffin footer. Databricks writes DVs for IcebergCompatV3 (UniForm) tables
+// this way: a Delta deletion_vector_*.bin file with a one-byte version
+// prefix and no Puffin header or footer.
+//
+// Without footer metadata the blob's type, referenced data file and
+// cardinality property cannot be cross-checked; the blob's own length,
+// magic and CRC-32 are still verified by DeserializeDV, and the decoded
+// cardinality is validated against the manifest record_count.
+//
+// dvFiles must already have passed validateDVFile (both callers validate
+// before dispatching here) and must all point at f. Blobs are read in
+// content_offset order to avoid backward seeks; results keep dvFiles order.
+func readBareDVs(f iceio.File, dvFiles []iceberg.DataFile) 
([]*RoaringPositionBitmap, error) {
+       filePath := dvFiles[0].FilePath()
+
+       order := make([]int, len(dvFiles))
+       for i := range order {
+               order[i] = i
+       }
+       offsetOf := func(i int) int64 {
+               _, _, _, contentOffset, _ := 
iceberginternal.BorrowedDataFilePointers(dvFiles[i])
+
+               return *contentOffset
+       }
+       slices.SortFunc(order, func(a, b int) int { return 
cmp.Compare(offsetOf(a), offsetOf(b)) })
+
+       bitmaps := make([]*RoaringPositionBitmap, len(dvFiles))
+       for _, i := range order {
+               dvFile := dvFiles[i]
+               _, _, _, contentOffset, contentSize := 
iceberginternal.BorrowedDataFilePointers(dvFile)
+               offset, size := *contentOffset, *contentSize
+
+               data := make([]byte, size)
+               if _, err := f.ReadAt(data, offset); err != nil {
+                       return nil, fmt.Errorf("%w: DV file %s is not a Puffin 
container; direct read of %d bytes at offset %d: %w",
+                               ErrInvalidDeletionVector, filePath, size, 
offset, err)
+               }
+
+               bitmap, err := DeserializeDV(data, dvFile.Count())
+               if err != nil {
+                       return nil, fmt.Errorf("%w: DV file %s is not a Puffin 
container; blob at offset %d: %w",
+                               ErrInvalidDeletionVector, filePath, offset, err)
+               }
+               bitmaps[i] = bitmap
+
+               if _, seen := bareDVWarned.LoadOrStore(filePath, struct{}{}); 
!seen {
+                       slog.Warn("DV file is not a Puffin container; reading 
deletion-vector-v1 blobs directly at content_offset, footer metadata validation 
skipped",
+                               "dv_file", filePath)
+               }
+       }
+
+       return bitmaps, nil
+}
+
 func validateDVFile(dvFile iceberg.DataFile) error {
        if dvFile.FileFormat() != iceberg.PuffinFile {
                return fmt.Errorf("expected PUFFIN format for deletion vector, 
got %s", dvFile.FileFormat())
@@ -348,6 +422,10 @@ func validateDVFile(dvFile iceberg.DataFile) error {
        return nil
 }
 
+// openDVReader opens the DV file and its Puffin footer. When the file is not
+// a Puffin container (puffin.ErrNotPuffinFile) it returns a nil reader and
+// the still-open file, so the caller can read bare blobs without a second
+// Open round-trip; the caller owns closing f whenever err is nil.
 func openDVReader(fs iceio.IO, filePath string) (*puffin.Reader, iceio.File, 
error) {
        f, err := fs.Open(filePath)
        if err != nil {
@@ -355,6 +433,9 @@ func openDVReader(fs iceio.IO, filePath string) 
(*puffin.Reader, iceio.File, err
        }
 
        reader, err := puffin.NewReader(f)
+       if errors.Is(err, puffin.ErrNotPuffinFile) {
+               return nil, f, nil
+       }
        if err != nil {
                _ = f.Close()
 
diff --git a/table/dv/deletion_vector_test.go b/table/dv/deletion_vector_test.go
index 7192b1238..e9333d234 100644
--- a/table/dv/deletion_vector_test.go
+++ b/table/dv/deletion_vector_test.go
@@ -27,6 +27,7 @@ import (
        "os"
        "path/filepath"
        "strconv"
+       "strings"
        "testing"
 
        "github.com/apache/iceberg-go"
@@ -876,7 +877,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")
+       })
+
+       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

Reply via email to