This is an automated email from the ASF dual-hosted git repository.
zeroshade 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 a19082e2d feat(puffin): write compressed blobs (#2401)
a19082e2d is described below
commit a19082e2d50d24d27710968d759338d931ba8442
Author: Minh Vu <[email protected]>
AuthorDate: Wed Nov 4 19:05:04 2026 +0100
feat(puffin): write compressed blobs (#2401)
* feat(puffin): read compressed blobs
Signed-off-by: Minh Vu <[email protected]>
* fix(puffin): reject trailing zstd frames
Signed-off-by: Minh Vu <[email protected]>
* feat(puffin): write compressed blobs
Signed-off-by: Minh Vu <[email protected]>
* fix(puffin): validate metadata first and verify frame sizes
* fix(puffin): reject compressed deletion vectors on read
* test(puffin): reject compressed DV footer and external metadata
* test(puffin): verify offsets of mixed-codec blobs
* fix(puffin): allow common zstd windows for small blobs
Signed-off-by: Minh Vu <[email protected]>
* fix(puffin): defer DV compression rejection to blob reads
* test(puffin): preserve DV reader error contract
* style(puffin): use complete ASF license header
* style(puffin): use complete ASF license header
* docs(puffin): clarify per-blob compression input
* test(puffin): cover LZ4 frame checksum and raw blocks
* fix(puffin): reject explicit empty compression codec
* refactor(puffin): share LZ4 and Zstd codec constants
* fix(puffin): handle unused encoder cleanup result
---------
Signed-off-by: Minh Vu <[email protected]>
---
puffin/blob_writer_compression.go | 110 +++++++++++++++
puffin/blob_writer_compression_test.go | 249 +++++++++++++++++++++++++++++++++
puffin/puffin_writer.go | 25 +++-
3 files changed, 381 insertions(+), 3 deletions(-)
diff --git a/puffin/blob_writer_compression.go
b/puffin/blob_writer_compression.go
new file mode 100644
index 000000000..4203f64a6
--- /dev/null
+++ b/puffin/blob_writer_compression.go
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package puffin
+
+import (
+ "bytes"
+ "fmt"
+ "sync"
+
+ "github.com/klauspost/compress/zstd"
+ "github.com/pierrec/lz4/v4"
+)
+
+// compressBlob returns one complete frame, including the uncompressed content
+// size required by the Puffin format. The caller must not compress DVs.
+func compressBlob(codec string, data []byte) ([]byte, error) {
+ switch codec {
+ case "":
+ return data, nil
+ case CompressionCodecLZ4:
+ // The lz4 library intentionally omits the content-size field
when
+ // SizeOption(0) is used. Write the empty frame explicitly
instead.
+ if len(data) == 0 {
+ return emptyLZ4Frame(), nil
+ }
+ var buf bytes.Buffer
+ writer := lz4.NewWriter(&buf)
+ if err := writer.Apply(lz4.SizeOption(uint64(len(data))),
lz4.ConcurrencyOption(1)); err != nil {
+ return nil, fmt.Errorf("puffin: configure lz4
compression: %w", err)
+ }
+ if _, err := writer.Write(data); err != nil {
+ return nil, fmt.Errorf("puffin: compress lz4 blob: %w",
err)
+ }
+ if err := writer.Close(); err != nil {
+ return nil, fmt.Errorf("puffin: finalize lz4 blob: %w",
err)
+ }
+
+ return buf.Bytes(), nil
+ case CompressionCodecZstd:
+ // Short Zstd frames need the single-segment flag to include a
content size.
+ encoder, err := sharedZstdBlobEncoder(len(data) < 256)
+ if err != nil {
+ return nil, err
+ }
+
+ return encoder.EncodeAll(data, nil), nil
+ default:
+ return nil, fmt.Errorf("puffin: unsupported blob compression
codec %q", codec)
+ }
+}
+
+// emptyLZ4Frame is an independent-block frame with an explicit zero content
+// size. LZ4 SizeOption(0) omits that field. The header checksum (0x12) is
+// verified by lz4.ValidFrameHeader in the writer tests.
+func emptyLZ4Frame() []byte {
+ return []byte{
+ 0x04, 0x22, 0x4d, 0x18, // frame magic
+ 0x68, 0x70, // frame flags and 4 MiB block size
+ 0, 0, 0, 0, 0, 0, 0, 0, // content size: zero
+ 0x12, // frame descriptor checksum
+ 0, 0, 0, 0, // end mark
+ }
+}
+
+var (
+ zstdBlobEncoderOnce sync.Once
+ zstdBlobEncoder *zstd.Encoder
+ zstdBlobSingleSegmentEncoder *zstd.Encoder
+ zstdBlobEncoderErr error
+)
+
+// sharedZstdBlobEncoder lazily creates two reusable encoders. EncodeAll can be
+// called concurrently and does not require resetting or closing the encoder.
+func sharedZstdBlobEncoder(singleSegment bool) (*zstd.Encoder, error) {
+ zstdBlobEncoderOnce.Do(func() {
+ options := []zstd.EOption{zstd.WithEncoderConcurrency(1),
zstd.WithZeroFrames(true)}
+ zstdBlobEncoder, zstdBlobEncoderErr = zstd.NewWriter(nil,
options...)
+ if zstdBlobEncoderErr != nil {
+ return
+ }
+ zstdBlobSingleSegmentEncoder, zstdBlobEncoderErr =
zstd.NewWriter(
+ nil, append(options, zstd.WithSingleSegment(true))...,
+ )
+ if zstdBlobEncoderErr != nil {
+ _ = zstdBlobEncoder.Close()
+ }
+ })
+ if zstdBlobEncoderErr != nil {
+ return nil, fmt.Errorf("puffin: configure zstd compression:
%w", zstdBlobEncoderErr)
+ }
+ if singleSegment {
+ return zstdBlobSingleSegmentEncoder, nil
+ }
+
+ return zstdBlobEncoder, nil
+}
diff --git a/puffin/blob_writer_compression_test.go
b/puffin/blob_writer_compression_test.go
new file mode 100644
index 000000000..02c49c216
--- /dev/null
+++ b/puffin/blob_writer_compression_test.go
@@ -0,0 +1,249 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package puffin_test
+
+import (
+ "bytes"
+ "encoding/binary"
+ "strconv"
+ "testing"
+
+ "github.com/apache/iceberg-go/puffin"
+ "github.com/klauspost/compress/zstd"
+ "github.com/pierrec/lz4/v4"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWriteCompressedBlobs(t *testing.T) {
+ for _, codec := range []string{"", puffin.CompressionCodecLZ4,
puffin.CompressionCodecZstd} {
+ for _, input := range [][]byte{nil, []byte("hello, Iceberg!"),
bytes.Repeat([]byte("Iceberg"), 2000)} {
+ t.Run(codec+"/"+strconv.Itoa(len(input)), func(t
*testing.T) {
+ t.Parallel()
+ var file bytes.Buffer
+ writer, err := puffin.NewWriter(&file)
+ require.NoError(t, err)
+ inputMeta := puffin.BlobMetadataInput{
+ Type:
puffin.BlobTypeDataSketchesTheta,
+ Fields: []int32{12},
+ SnapshotID: 5,
+ SequenceNumber: 3,
+ CompressionCodec: codec,
+ }
+ meta, err := writer.AddBlob(inputMeta, input)
+ require.NoError(t, err)
+ require.NoError(t, writer.Finish())
+ require.Equal(t, int64(puffin.MagicSize),
meta.Offset)
+ if codec != "" {
+ require.Greater(t, meta.Length,
int64(0), "even an empty compressed frame has bytes")
+ }
+ if codec == "" {
+ require.Nil(t, meta.CompressionCodec)
+ } else {
+ require.Equal(t, codec,
*meta.CompressionCodec)
+ }
+ reader, err :=
puffin.NewReader(bytes.NewReader(file.Bytes()))
+ require.NoError(t, err)
+ blobs := reader.Blobs()
+ require.Len(t, blobs, 1)
+ require.Equal(t, meta, blobs[0])
+ encoded := file.Bytes()[meta.Offset :
meta.Offset+meta.Length]
+ switch codec {
+ case "":
+ require.Equal(t, string(input),
string(encoded))
+ case puffin.CompressionCodecLZ4:
+ // Puffin requires the LZ4 content-size
flag, including for empty blobs.
+ require.GreaterOrEqual(t, len(encoded),
19)
+ require.Equal(t, uint32(0x184D2204),
binary.LittleEndian.Uint32(encoded[:4]))
+ require.NotZero(t, encoded[4]&0x08)
+ require.Equal(t, uint64(len(input)),
binary.LittleEndian.Uint64(encoded[6:14]))
+ validHeader, headerErr :=
lz4.ValidFrameHeader(encoded[:15])
+ require.NoError(t, headerErr)
+ require.True(t, validHeader, "LZ4 frame
header checksum must be valid")
+ case puffin.CompressionCodecZstd:
+ var header zstd.Header
+ require.NoError(t,
header.Decode(encoded))
+ require.True(t, header.HasFCS)
+ require.Equal(t, uint64(len(input)),
header.FrameContentSize)
+ }
+
+ blob, err := reader.ReadBlob(0)
+ require.NoError(t, err)
+ require.Equal(t, string(input),
string(blob.Data))
+ require.Equal(t, meta, blob.Metadata)
+ byMetadata, err :=
reader.ReadBlobByMetadata(meta)
+ require.NoError(t, err)
+ require.Equal(t, string(input),
string(byMetadata))
+ all, err := reader.ReadAllBlobs()
+ require.NoError(t, err)
+ require.Len(t, all, 1)
+ require.Equal(t, string(input),
string(all[0].Data))
+ })
+ }
+ }
+}
+
+func TestZstdBlobFrameContentSizeBoundaries(t *testing.T) {
+ for _, size := range []int{0, 1, 15, 255, 256, 65535, 65536} {
+ t.Run(strconv.Itoa(size), func(t *testing.T) {
+ var output bytes.Buffer
+ writer, err := puffin.NewWriter(&output)
+ require.NoError(t, err)
+ meta, err := writer.AddBlob(puffin.BlobMetadataInput{
+ Type: puffin.BlobTypeDataSketchesTheta, Fields:
[]int32{1}, CompressionCodec: puffin.CompressionCodecZstd,
+ }, bytes.Repeat([]byte{42}, size))
+ require.NoError(t, err)
+ var header zstd.Header
+ require.NoError(t,
header.Decode(output.Bytes()[meta.Offset:meta.Offset+meta.Length]))
+ require.True(t, header.HasFCS)
+ require.Equal(t, uint64(size), header.FrameContentSize)
+ require.NoError(t, writer.Finish())
+ reader, err :=
puffin.NewReader(bytes.NewReader(output.Bytes()))
+ require.NoError(t, err)
+ blob, err := reader.ReadBlob(0)
+ require.NoError(t, err)
+ require.Equal(t, string(bytes.Repeat([]byte{42},
size)), string(blob.Data))
+ })
+ }
+}
+
+func TestCompressedWriterRejectsInvalidCodecWithoutWriting(t *testing.T) {
+ var buf bytes.Buffer
+ w, err := puffin.NewWriter(&buf)
+ require.NoError(t, err)
+ n := buf.Len()
+ _, err = w.AddBlob(puffin.BlobMetadataInput{
+ Type: puffin.BlobTypeDataSketchesTheta,
+ Fields: []int32{3},
+ CompressionCodec: "gzip",
+ }, []byte("payload"))
+ require.ErrorContains(t, err, "unsupported blob compression codec")
+ require.Equal(t, n, buf.Len())
+ _, err = w.AddBlob(puffin.BlobMetadataInput{
+ Type: puffin.BlobTypeDeletionVector,
+ Fields: []int32{},
+ SnapshotID: -1,
+ SequenceNumber: -1,
+ CompressionCodec: puffin.CompressionCodecLZ4,
+ }, []byte("payload"))
+ require.ErrorContains(t, err, "must not be compressed")
+ require.Equal(t, n, buf.Len())
+ _, err = w.AddBlob(puffin.BlobMetadataInput{Type:
puffin.BlobTypeDataSketchesTheta, Fields: []int32{}}, []byte("ok"))
+ require.NoError(t, err)
+ require.NoError(t, w.Finish())
+}
+
+func TestLZ4BlobSpanningMultipleBlocks(t *testing.T) {
+ data := bytes.Repeat([]byte("abcd1234"), (4<<20)/8+1)
+ var output bytes.Buffer
+ writer, err := puffin.NewWriter(&output)
+ require.NoError(t, err)
+ meta, err := writer.AddBlob(puffin.BlobMetadataInput{
+ Type: puffin.BlobTypeDataSketchesTheta, Fields: []int32{1},
CompressionCodec: puffin.CompressionCodecLZ4,
+ }, data)
+ require.NoError(t, err)
+ require.NoError(t, writer.Finish())
+ encoded := output.Bytes()[meta.Offset : meta.Offset+meta.Length]
+ require.NotZero(t, encoded[4]&0x08)
+ require.Equal(t, uint64(len(data)),
binary.LittleEndian.Uint64(encoded[6:14]))
+ reader, err := puffin.NewReader(bytes.NewReader(output.Bytes()))
+ require.NoError(t, err)
+ blob, err := reader.ReadBlob(0)
+ require.NoError(t, err)
+ require.Equal(t, data, blob.Data)
+}
+
+// Incompressible input exercises LZ4's uncompressed-block fallback, rather
+// than only the compressed blocks covered by the other writer tests.
+func TestLZ4BlobWithUncompressedBlock(t *testing.T) {
+ data := make([]byte, 64<<10)
+ state := uint32(0x12345678)
+ for i := range data {
+ state ^= state << 13
+ state ^= state >> 17
+ state ^= state << 5
+ data[i] = byte(state)
+ }
+
+ var file bytes.Buffer
+ writer, err := puffin.NewWriter(&file)
+ require.NoError(t, err)
+ meta, err := writer.AddBlob(puffin.BlobMetadataInput{
+ Type: puffin.BlobTypeDataSketchesTheta, Fields: []int32{1},
CompressionCodec: puffin.CompressionCodecLZ4,
+ }, data)
+ require.NoError(t, err)
+ require.NoError(t, writer.Finish())
+
+ frame := file.Bytes()[meta.Offset : meta.Offset+meta.Length]
+ require.GreaterOrEqual(t, len(frame), 19)
+ require.NotZero(t, binary.LittleEndian.Uint32(frame[15:19])&(1<<31),
+ "LZ4 should store incompressible blocks uncompressed")
+ reader, err := puffin.NewReader(bytes.NewReader(file.Bytes()))
+ require.NoError(t, err)
+ blob, err := reader.ReadBlob(0)
+ require.NoError(t, err)
+ require.Equal(t, data, blob.Data)
+}
+
+func TestMixedBlobCompressionUsesStoredOffsets(t *testing.T) {
+ var file bytes.Buffer
+ writer, err := puffin.NewWriter(&file)
+ require.NoError(t, err)
+
+ blobs := []struct {
+ codec string
+ data []byte
+ }{
+ {codec: "", data: []byte("raw payload")},
+ {codec: puffin.CompressionCodecLZ4, data:
bytes.Repeat([]byte("lz4 payload"), 512)},
+ {codec: puffin.CompressionCodecZstd, data:
bytes.Repeat([]byte("zstd payload"), 512)},
+ }
+ previousEnd := int64(puffin.MagicSize)
+ for _, blob := range blobs {
+ meta, err := writer.AddBlob(puffin.BlobMetadataInput{
+ Type: puffin.BlobTypeDataSketchesTheta,
+ Fields: []int32{1},
+ CompressionCodec: blob.codec,
+ }, blob.data)
+ require.NoError(t, err)
+ require.Equal(t, previousEnd, meta.Offset)
+ previousEnd = meta.Offset + meta.Length
+ }
+ require.NoError(t, writer.Finish())
+
+ reader, err := puffin.NewReader(bytes.NewReader(file.Bytes()))
+ require.NoError(t, err)
+ metadata := reader.Blobs()
+ require.Len(t, metadata, len(blobs))
+ for i, blob := range blobs {
+ meta := metadata[i]
+ if blob.codec == "" {
+ require.Nil(t, meta.CompressionCodec)
+ } else {
+ require.Equal(t, blob.codec, *meta.CompressionCodec)
+ }
+ got, err := reader.ReadBlob(i)
+ require.NoError(t, err)
+ require.Equal(t, blob.data, got.Data)
+ require.Equal(t, meta, got.Metadata)
+ byMetadata, err := reader.ReadBlobByMetadata(meta)
+ require.NoError(t, err)
+ require.Equal(t, blob.data, byMetadata)
+ }
+ all, err := reader.ReadAllBlobs()
+ require.NoError(t, err)
+ require.Len(t, all, len(blobs))
+}
diff --git a/puffin/puffin_writer.go b/puffin/puffin_writer.go
index 113206c11..4da2849da 100644
--- a/puffin/puffin_writer.go
+++ b/puffin/puffin_writer.go
@@ -61,13 +61,17 @@ type Writer struct {
}
// BlobMetadataInput contains fields the caller provides when adding a blob.
-// Offset, Length, and CompressionCodec are set by the writer.
+// Offset and Length are computed by the writer. CompressionCodec selects the
+// blob's compression and is omitted from footer metadata when empty.
type BlobMetadataInput struct {
Type BlobType
SnapshotID int64
SequenceNumber int64
Fields []int32
Properties map[string]string
+ // CompressionCodec selects CompressionCodecLZ4 or CompressionCodecZstd.
+ // An empty value writes raw bytes. Deletion vectors must not be
compressed.
+ CompressionCodec string
}
// NewWriter creates a new Writer and writes the file header magic.
@@ -155,6 +159,10 @@ func (w *Writer) AddBlob(input BlobMetadataInput, data
[]byte) (BlobMetadata, er
fields := slices.Clone(input.Fields)
properties := maps.Clone(input.Properties)
+ if input.Type == BlobTypeDeletionVector && input.CompressionCodec != ""
{
+ return BlobMetadata{}, errors.New("puffin: deletion-vector-v1
must not be compressed")
+ }
+
// Deletion vectors have special requirements per spec. Format matches
// the reader-side validateBlobs check so both sides surface the same
// "<type> requires X to be -1, got N" phrasing for log grep.
@@ -190,17 +198,28 @@ func (w *Writer) AddBlob(input BlobMetadataInput, data
[]byte) (BlobMetadata, er
}
}
+ // Validate metadata before compressing potentially large blobs. Encode
before
+ // writing any bytes so compression failures cannot leave partial
output.
+ encoded, err := compressBlob(input.CompressionCodec, data)
+ if err != nil {
+ return BlobMetadata{}, err
+ }
+
meta := BlobMetadata{
Type: input.Type,
SnapshotID: input.SnapshotID,
SequenceNumber: input.SequenceNumber,
Fields: fields,
Offset: w.offset,
- Length: int64(len(data)),
+ Length: int64(len(encoded)),
Properties: properties,
}
+ if input.CompressionCodec != "" {
+ codec := input.CompressionCodec
+ meta.CompressionCodec = &codec
+ }
- if err := w.write(data); err != nil {
+ if err := w.write(encoded); err != nil {
return BlobMetadata{}, fmt.Errorf("puffin: write blob: %w", err)
}