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 45d5a65c1 feat(table): register Puffin statistics in transactions
(#2437)
45d5a65c1 is described below
commit 45d5a65c16b8c58bcccd3aa0d699f218a4821cfe
Author: Minh Vu <[email protected]>
AuthorDate: Mon Nov 9 19:01:56 2026 +0100
feat(table): register Puffin statistics in transactions (#2437)
* feat(table): register Puffin statistics files in transactions
* test(table): cover Puffin statistics registration and validation
* fix(table): align Puffin statistics registration with Java
* test(table): assert unchanged metadata for no-op statistics transaction
* test(table): preserve registered statistics on unmatched removal
* fix(table): address Puffin statistics review feedback
* test(puffin): cover writer size accessors across codecs
---
puffin/puffin_writer.go | 37 +++-
puffin/puffin_writer_state_test.go | 99 +++++++++
table/metadata.go | 10 +-
table/partition_statistics_write.go | 4 -
table/partition_statistics_write_test.go | 23 +++
table/statistics_write.go | 56 ++++++
table/statistics_write_test.go | 331 +++++++++++++++++++++++++++++++
website/src/feature-status.md | 2 +-
8 files changed, 548 insertions(+), 14 deletions(-)
diff --git a/puffin/puffin_writer.go b/puffin/puffin_writer.go
index 4da2849da..a639b8465 100644
--- a/puffin/puffin_writer.go
+++ b/puffin/puffin_writer.go
@@ -51,13 +51,14 @@ import (
// Writer cannot be reused after a physical write fails. Once poisoned, all
// subsequent operations return the original write error without changing
state.
type Writer struct {
- w io.Writer
- offset int64
- blobs []BlobMetadata
- props map[string]string
- done bool
- failed error
- createdBy string
+ w io.Writer
+ offset int64
+ footerSize int64
+ blobs []BlobMetadata
+ props map[string]string
+ done bool
+ failed error
+ createdBy string
}
// BlobMetadataInput contains fields the caller provides when adding a blob.
@@ -94,6 +95,26 @@ func NewWriter(w io.Writer) (*Writer, error) {
}, nil
}
+// FileSizeInBytes returns the total completed file size, including the header,
+// blobs and footer. It returns zero until Finish succeeds.
+func (w *Writer) FileSizeInBytes() int64 {
+ if !w.done {
+ return 0
+ }
+
+ return w.offset
+}
+
+// FileFooterSizeInBytes returns the complete footer size, including the
+// leading magic, payload and trailer. It returns zero until Finish succeeds.
+func (w *Writer) FileFooterSizeInBytes() int64 {
+ if !w.done {
+ return 0
+ }
+
+ return w.footerSize
+}
+
// checkState keeps the writer's finalized and failed-state checks consistent
// across operations. State checks must run before validating any input.
func (w *Writer) checkState(action string) error {
@@ -291,6 +312,8 @@ func (w *Writer) Finish() error {
return fmt.Errorf("puffin: write footer trailer: %w", err)
}
+ w.footerSize = int64(MagicSize + len(payload) + footerTrailerSize)
+ w.offset += w.footerSize
w.done = true
return nil
diff --git a/puffin/puffin_writer_state_test.go
b/puffin/puffin_writer_state_test.go
index 8e66695d5..a6264841e 100644
--- a/puffin/puffin_writer_state_test.go
+++ b/puffin/puffin_writer_state_test.go
@@ -18,6 +18,8 @@
package puffin
import (
+ "bytes"
+ "encoding/binary"
"errors"
"io"
"testing"
@@ -82,3 +84,100 @@ func TestWriterStateGuardErrors(t *testing.T) {
})
}
}
+
+func TestWriterReportsCompletedSizes(t *testing.T) {
+ var output bytes.Buffer
+ writer, err := NewWriter(&output)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if writer.FileSizeInBytes() != 0 || writer.FileFooterSizeInBytes() != 0
{
+ t.Fatal("file sizes must be unavailable before Finish")
+ }
+
+ _, err = writer.AddBlob(BlobMetadataInput{
+ Type: BlobTypeDataSketchesTheta,
+ Fields: []int32{1},
+ }, []byte("blob"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if writer.FileSizeInBytes() != 0 || writer.FileFooterSizeInBytes() != 0
{
+ t.Fatal("file sizes must be unavailable until Finish")
+ }
+ if err := writer.Finish(); err != nil {
+ t.Fatal(err)
+ }
+
+ data := output.Bytes()
+ trailer := data[len(data)-footerTrailerSize:]
+ payloadSize := int64(binary.LittleEndian.Uint32(trailer[:4]))
+ wantFooterSize := int64(MagicSize+footerTrailerSize) + payloadSize
+ if got := writer.FileFooterSizeInBytes(); got != wantFooterSize {
+ t.Fatalf("footer size = %d, want %d", got, wantFooterSize)
+ }
+ if got := writer.FileSizeInBytes(); got != int64(len(data)) {
+ t.Fatalf("file size = %d, want %d", got, len(data))
+ }
+}
+
+func TestWriterSizeAccessorsRoundTrip(t *testing.T) {
+ cases := []struct {
+ name string
+ codec string
+ blob bool
+ }{
+ {name: "no blobs"},
+ {name: "lz4", codec: CompressionCodecLZ4, blob: true},
+ {name: "zstd", codec: CompressionCodecZstd, blob: true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var output bytes.Buffer
+ writer, err := NewWriter(&output)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ input := bytes.Repeat([]byte("iceberg-puffin"), 64)
+ if tc.blob {
+ _, err := writer.AddBlob(BlobMetadataInput{
+ Type:
BlobTypeDataSketchesTheta,
+ Fields: []int32{1},
+ CompressionCodec: tc.codec,
+ }, input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if err := writer.Finish(); err != nil {
+ t.Fatal(err)
+ }
+ reader, err :=
NewReader(bytes.NewReader(output.Bytes()))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if got, want := writer.FileSizeInBytes(),
int64(output.Len()); got != want {
+ t.Fatalf("file size = %d, want %d", got, want)
+ }
+ if got, want := writer.FileFooterSizeInBytes(),
int64(output.Len())-reader.footerStart; got != want {
+ t.Fatalf("footer size = %d, want %d", got, want)
+ }
+
+ if tc.blob {
+ result, err := reader.ReadBlob(0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(result.Data, input) {
+ t.Fatal("Puffin round trip changed blob
contents")
+ }
+ } else if len(reader.Blobs()) != 0 {
+ t.Fatal("empty Puffin file unexpectedly
contains blobs")
+ }
+ })
+ }
+}
diff --git a/table/metadata.go b/table/metadata.go
index 01e203b18..d7ae9dd80 100644
--- a/table/metadata.go
+++ b/table/metadata.go
@@ -2021,10 +2021,13 @@ func (b *MetadataBuilder) SetStatistics(stats
StatisticsFile) error {
// RemoveStatistics removes the statistics file associated with the given
snapshot ID.
// It is not an error if no such file exists.
func (b *MetadataBuilder) RemoveStatistics(snapshotID int64) error {
+ previousLen := len(b.statisticsList)
b.statisticsList = slices.DeleteFunc(b.statisticsList, func(s
StatisticsFile) bool {
return s.SnapshotID == snapshotID
})
- b.updates = append(b.updates, NewRemoveStatisticsUpdate(snapshotID))
+ if len(b.statisticsList) != previousLen {
+ b.updates = append(b.updates,
NewRemoveStatisticsUpdate(snapshotID))
+ }
return nil
}
@@ -2055,10 +2058,13 @@ func (b *MetadataBuilder) SetPartitionStatistics(stats
PartitionStatisticsFile)
// RemovePartitionStatistics removes the partition statistics file associated
with the given
// snapshot ID. It is not an error if no such file exists.
func (b *MetadataBuilder) RemovePartitionStatistics(snapshotID int64) error {
+ previousLen := len(b.partitionStatsList)
b.partitionStatsList = slices.DeleteFunc(b.partitionStatsList, func(s
PartitionStatisticsFile) bool {
return s.SnapshotID == snapshotID
})
- b.updates = append(b.updates,
NewRemovePartitionStatisticsUpdate(snapshotID))
+ if len(b.partitionStatsList) != previousLen {
+ b.updates = append(b.updates,
NewRemovePartitionStatisticsUpdate(snapshotID))
+ }
return nil
}
diff --git a/table/partition_statistics_write.go
b/table/partition_statistics_write.go
index ccb2a9a2f..d740a91ad 100644
--- a/table/partition_statistics_write.go
+++ b/table/partition_statistics_write.go
@@ -75,10 +75,6 @@ func (t *Transaction) SetPartitionStatistics(stats
PartitionStatisticsFile) erro
// RemovePartitionStatistics removes the partition statistics file reference
// associated with snapshotID. It does not delete the physical statistics file.
func (t *Transaction) RemovePartitionStatistics(snapshotID int64) error {
- if _, err := t.txnMeta(); err != nil {
- return err
- }
-
return
t.apply([]Update{NewRemovePartitionStatisticsUpdate(snapshotID)}, nil)
}
diff --git a/table/partition_statistics_write_test.go
b/table/partition_statistics_write_test.go
index 35fe8bdba..52b2d111d 100644
--- a/table/partition_statistics_write_test.go
+++ b/table/partition_statistics_write_test.go
@@ -361,6 +361,29 @@ func TestTransactionPartitionStatisticsRegistration(t
*testing.T) {
require.Error(t, nilTx.SetPartitionStatistics(stats))
}
+func TestTransactionRemovePartitionStatisticsNoOp(t *testing.T) {
+ tbl := inspectTestTable(Identifier{"db", "stats"},
map[string]SnapshotRef{
+ MainBranch: {SnapshotID: 103, Type: BranchRef},
+ })
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.RemovePartitionStatistics(103))
+ require.NoError(t, tx.RemovePartitionStatistics(999))
+ require.Empty(t, tx.meta.updates)
+
+ stats := PartitionStatisticsFile{
+ SnapshotID: 103, StatisticsPath:
"s3://warehouse/db/stats/metadata/stats.parquet", FileSizeInBytes: 512,
+ }
+ require.NoError(t, tx.SetPartitionStatistics(stats))
+ updateCount := len(tx.meta.updates)
+ require.NoError(t, tx.RemovePartitionStatistics(999))
+ require.Len(t, tx.meta.updates, updateCount)
+
+ require.NoError(t, tx.RemovePartitionStatistics(stats.SnapshotID))
+ updateCount = len(tx.meta.updates)
+ require.NoError(t, tx.RemovePartitionStatistics(stats.SnapshotID))
+ require.Len(t, tx.meta.updates, updateCount)
+}
+
func TestComputePartitionStatisticsRejectsNonV1AndMissingSnapshot(t
*testing.T) {
tbl := inspectTestTable(Identifier{"db", "stats"},
map[string]SnapshotRef{
MainBranch: {SnapshotID: 103, Type: BranchRef},
diff --git a/table/statistics_write.go b/table/statistics_write.go
new file mode 100644
index 000000000..39ca50032
--- /dev/null
+++ b/table/statistics_write.go
@@ -0,0 +1,56 @@
+// 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 table
+
+import (
+ "fmt"
+
+ "github.com/apache/iceberg-go"
+)
+
+// SetStatistics registers a previously written Puffin statistics file with
+// the file's snapshot. A file for the same snapshot replaces the previous
+// reference. This only stages a metadata update; Commit publishes it, and
+// the caller is responsible for writing a valid Puffin file beforehand.
+func (t *Transaction) SetStatistics(stats StatisticsFile) error {
+ if stats.SnapshotID <= 0 {
+ return fmt.Errorf("%w: statistics snapshot ID must be
positive", iceberg.ErrInvalidArgument)
+ }
+ if stats.StatisticsPath == "" || stats.FileSizeInBytes <= 0 ||
+ stats.FileFooterSizeInBytes <= 0 || stats.FileFooterSizeInBytes
> stats.FileSizeInBytes {
+ return fmt.Errorf("%w: statistics path must be nonempty, and
file and footer sizes must be positive with footer size at most file size",
+ iceberg.ErrInvalidArgument)
+ }
+ for idx, blob := range stats.BlobMetadata {
+ if blob.Type == "" {
+ return fmt.Errorf("%w: statistics blob %d requires a
type",
+ iceberg.ErrInvalidArgument, idx)
+ }
+ }
+ // A transaction may be committed after the caller has reused its
buffers.
+ // Keep the staged update independent of the caller's maps and slices.
+ registered := cloneStatisticsFiles([]StatisticsFile{stats})[0]
+
+ return t.apply([]Update{NewSetStatisticsUpdate(registered)}, nil)
+}
+
+// RemoveStatistics removes the statistics file reference for snapshotID. An
+// absent reference is not an error. The physical Puffin file is not deleted.
+func (t *Transaction) RemoveStatistics(snapshotID int64) error {
+ return t.apply([]Update{NewRemoveStatisticsUpdate(snapshotID)}, nil)
+}
diff --git a/table/statistics_write_test.go b/table/statistics_write_test.go
new file mode 100644
index 000000000..f6fb2adc1
--- /dev/null
+++ b/table/statistics_write_test.go
@@ -0,0 +1,331 @@
+// 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 table
+
+import (
+ "context"
+ "encoding/json"
+ "slices"
+ "testing"
+
+ "github.com/apache/iceberg-go"
+ iceio "github.com/apache/iceberg-go/io"
+ "github.com/apache/iceberg-go/puffin"
+ "github.com/stretchr/testify/require"
+)
+
+func testStatisticsRegistrationFile() StatisticsFile {
+ return StatisticsFile{
+ SnapshotID: 103,
+ StatisticsPath:
"s3://warehouse/db/stats/metadata/sketch.puffin",
+ FileSizeInBytes: 1024,
+ FileFooterSizeInBytes: 128,
+ BlobMetadata: []BlobMetadata{{
+ Type: puffin.BlobTypeDataSketchesTheta,
+ SnapshotID: 103,
+ SequenceNumber: 3,
+ Fields: []int32{1},
+ Properties: map[string]string{"creator": "go"},
+ }},
+ }
+}
+
+func TestTransactionStatisticsRegistration(t *testing.T) {
+ tbl := inspectTestTable(Identifier{"db", "stats"},
map[string]SnapshotRef{
+ MainBranch: {SnapshotID: 103, Type: BranchRef},
+ })
+ tx := tbl.NewTransaction()
+ stats := testStatisticsRegistrationFile()
+
+ require.NoError(t, tx.SetStatistics(stats))
+ meta, err := tx.meta.Build()
+ require.NoError(t, err)
+ require.Equal(t, []StatisticsFile{stats},
slices.Collect(meta.Statistics()))
+
+ // Removing another snapshot must not stage a metadata update.
+ stagedUpdateCount := len(tx.meta.updates)
+ require.NoError(t, tx.RemoveStatistics(999))
+ require.Len(t, tx.meta.updates, stagedUpdateCount)
+ meta, err = tx.meta.Build()
+ require.NoError(t, err)
+ require.Equal(t, []StatisticsFile{stats},
slices.Collect(meta.Statistics()))
+
+ replacement := stats
+ replacement.StatisticsPath =
"s3://warehouse/db/stats/metadata/replaced.puffin"
+ replacement.FileSizeInBytes = 2048
+ require.NoError(t, tx.SetStatistics(replacement))
+ meta, err = tx.meta.Build()
+ require.NoError(t, err)
+ require.Equal(t, []StatisticsFile{replacement},
slices.Collect(meta.Statistics()))
+
+ require.NoError(t, tx.RemoveStatistics(stats.SnapshotID))
+ meta, err = tx.meta.Build()
+ require.NoError(t, err)
+ require.Empty(t, slices.Collect(meta.Statistics()))
+
+ // Java's removeStatistics is idempotent if no reference exists.
+ updateCount := len(tx.meta.updates)
+ require.NoError(t, tx.RemoveStatistics(stats.SnapshotID))
+ require.NoError(t, tx.RemoveStatistics(999))
+ require.Len(t, tx.meta.updates, updateCount)
+}
+
+func TestTransactionStatisticsMetadataRoundTrip(t *testing.T) {
+ base, err := getTestTableMetadata("TableMetadataV2Valid.json")
+ require.NoError(t, err)
+ require.NotEmpty(t, base.Snapshots())
+ tbl := New(Identifier{"db", "stats"}, base, "", nil, nil)
+ tx := tbl.NewTransaction()
+ stats := testStatisticsRegistrationFile()
+ stats.SnapshotID = base.Snapshots()[0].SnapshotID
+ stats.BlobMetadata[0].SnapshotID = stats.SnapshotID
+ stats.BlobMetadata[0].SequenceNumber =
base.Snapshots()[0].SequenceNumber
+ require.NoError(t, tx.SetStatistics(stats))
+
+ meta, err := tx.meta.Build()
+ require.NoError(t, err)
+ encoded, err := json.Marshal(meta)
+ require.NoError(t, err)
+ parsed, err := ParseMetadataBytes(encoded)
+ require.NoError(t, err)
+ require.Equal(t, []StatisticsFile{stats},
slices.Collect(parsed.Statistics()))
+}
+
+func TestTransactionStatisticsCatalogCommit(t *testing.T) {
+ base, err := getTestTableMetadata("TableMetadataV2Valid.json")
+ require.NoError(t, err)
+ require.NotEmpty(t, base.Snapshots())
+
+ cat := &sequentialCatalog{metadata: base}
+ ident := Identifier{"db", "stats"}
+ fs := iceio.NewMemFS()
+ tbl := New(ident, base, "mem://stats/metadata/v1.metadata.json",
+ func(context.Context) (iceio.IO, error) { return fs, nil }, cat)
+ tx := tbl.NewTransaction()
+
+ stats := testStatisticsRegistrationFile()
+ stats.SnapshotID = base.Snapshots()[0].SnapshotID
+ stats.BlobMetadata[0].SnapshotID = stats.SnapshotID
+ stats.BlobMetadata[0].SequenceNumber =
base.Snapshots()[0].SequenceNumber
+ require.NoError(t, tx.SetStatistics(stats))
+ committed, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+ require.NotNil(t, committed)
+
+ reloaded, err := cat.LoadTable(t.Context(), ident)
+ require.NoError(t, err)
+ require.Equal(t, []StatisticsFile{stats},
slices.Collect(reloaded.Metadata().Statistics()))
+ require.Equal(t, base.Snapshots(), reloaded.Metadata().Snapshots())
+ require.Equal(t, base.CurrentSnapshot(),
reloaded.Metadata().CurrentSnapshot())
+
+ removeTx := reloaded.NewTransaction()
+ require.NoError(t, removeTx.RemoveStatistics(stats.SnapshotID))
+ _, err = removeTx.Commit(t.Context())
+ require.NoError(t, err)
+
+ reloaded, err = cat.LoadTable(t.Context(), ident)
+ require.NoError(t, err)
+ require.Empty(t, slices.Collect(reloaded.Metadata().Statistics()))
+ require.Equal(t, base.Snapshots(), reloaded.Metadata().Snapshots())
+ require.Equal(t, base.CurrentSnapshot(),
reloaded.Metadata().CurrentSnapshot())
+}
+
+func TestTransactionSetStatisticsClonesCallerData(t *testing.T) {
+ tbl := inspectTestTable(Identifier{"db", "stats"},
map[string]SnapshotRef{
+ MainBranch: {SnapshotID: 103, Type: BranchRef},
+ })
+ tx := tbl.NewTransaction()
+ stats := testStatisticsRegistrationFile()
+ keyMetadata := "first"
+ stats.KeyMetadata = &keyMetadata
+ require.NoError(t, tx.SetStatistics(stats))
+
+ keyMetadata = "changed"
+ stats.BlobMetadata[0].Fields[0] = 99
+ stats.BlobMetadata[0].Properties["creator"] = "changed"
+ meta, err := tx.meta.Build()
+ require.NoError(t, err)
+ registered := slices.Collect(meta.Statistics())
+ require.Len(t, registered, 1)
+ require.Equal(t, "first", *registered[0].KeyMetadata)
+ require.Equal(t, []int32{1}, registered[0].BlobMetadata[0].Fields)
+ require.Equal(t, "go",
registered[0].BlobMetadata[0].Properties["creator"])
+}
+
+func TestTransactionSetStatisticsRejectsInvalidFiles(t *testing.T) {
+ tbl := inspectTestTable(Identifier{"db", "stats"},
map[string]SnapshotRef{
+ MainBranch: {SnapshotID: 103, Type: BranchRef},
+ })
+ cases := []struct {
+ name string
+ change func(*StatisticsFile)
+ }{
+ {"missing snapshot ID", func(s *StatisticsFile) { s.SnapshotID
= 0 }},
+ {"negative snapshot ID", func(s *StatisticsFile) { s.SnapshotID
= -1 }},
+ {"missing path", func(s *StatisticsFile) { s.StatisticsPath =
"" }},
+ {"nonpositive file size", func(s *StatisticsFile) {
s.FileSizeInBytes = 0 }},
+ {"negative file size", func(s *StatisticsFile) {
s.FileSizeInBytes = -1 }},
+ {"nonpositive footer size", func(s *StatisticsFile) {
s.FileFooterSizeInBytes = 0 }},
+ {"negative footer size", func(s *StatisticsFile) {
s.FileFooterSizeInBytes = -1 }},
+ {"footer exceeds file", func(s *StatisticsFile) {
s.FileFooterSizeInBytes = 1025 }},
+ {"empty blob type", func(s *StatisticsFile) {
s.BlobMetadata[0].Type = "" }},
+ {"empty second blob type", func(s *StatisticsFile) {
+ s.BlobMetadata = append(s.BlobMetadata, BlobMetadata{})
+ }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ tx := tbl.NewTransaction()
+ stats := testStatisticsRegistrationFile()
+ tc.change(&stats)
+ err := tx.SetStatistics(stats)
+ require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+ if stats.SnapshotID <= 0 {
+ require.ErrorContains(t, err, "snapshot ID")
+ }
+ require.Empty(t, tx.meta.updates)
+ })
+ }
+
+ var nilTx *Transaction
+ require.Error(t, nilTx.SetStatistics(testStatisticsRegistrationFile()))
+ require.Error(t, nilTx.RemoveStatistics(103))
+}
+
+func TestTransactionRemoveStatisticsNoOp(t *testing.T) {
+ tbl := inspectTestTable(Identifier{"db", "stats"},
map[string]SnapshotRef{
+ MainBranch: {SnapshotID: 103, Type: BranchRef},
+ })
+ tx := tbl.NewTransaction()
+
+ require.NoError(t, tx.RemoveStatistics(103))
+ require.NoError(t, tx.RemoveStatistics(999))
+ require.Empty(t, tx.meta.updates)
+
+ commit, err := tx.TableCommit()
+ require.NoError(t, err)
+ require.Empty(t, commit.Updates)
+
+ // A no-op must not try to write a new metadata file through a catalog.
+ committed, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+ require.Same(t, tbl.metadata, committed.metadata)
+ require.ErrorContains(t, tx.RemoveStatistics(103), "already been
committed")
+}
+
+func TestTransactionSetStatisticsAllowsUnretainedSnapshot(t *testing.T) {
+ tbl := inspectTestTable(Identifier{"db", "stats"},
map[string]SnapshotRef{
+ MainBranch: {SnapshotID: 103, Type: BranchRef},
+ })
+ tx := tbl.NewTransaction()
+ stats := testStatisticsRegistrationFile()
+ stats.SnapshotID = 999
+
+ // Java's TableMetadata.Builder.setStatistics does not require the
+ // statistics snapshot to be present in the current metadata.
+ require.NoError(t, tx.SetStatistics(stats))
+ meta, err := tx.meta.Build()
+ require.NoError(t, err)
+ require.Equal(t, []StatisticsFile{stats},
slices.Collect(meta.Statistics()))
+}
+
+func TestTransactionSetStatisticsAcceptsNilCollections(t *testing.T) {
+ tbl := inspectTestTable(Identifier{"db", "stats"},
map[string]SnapshotRef{
+ MainBranch: {SnapshotID: 103, Type: BranchRef},
+ })
+ cases := []struct {
+ name string
+ change func(*StatisticsFile)
+ wantBlobs int
+ }{
+ {"nil blob metadata", func(s *StatisticsFile) { s.BlobMetadata
= nil }, 0},
+ {"nil blob fields", func(s *StatisticsFile) {
s.BlobMetadata[0].Fields = nil }, 1},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ tx := tbl.NewTransaction()
+ stats := testStatisticsRegistrationFile()
+ tc.change(&stats)
+ require.NoError(t, tx.SetStatistics(stats))
+
+ meta, err := tx.meta.Build()
+ require.NoError(t, err)
+ encoded, err := json.Marshal(meta)
+ require.NoError(t, err)
+
+ var payload struct {
+ Statistics []struct {
+ Blobs json.RawMessage
`json:"blob-metadata"`
+ } `json:"statistics"`
+ }
+ require.NoError(t, json.Unmarshal(encoded, &payload))
+ require.Len(t, payload.Statistics, 1)
+ if tc.wantBlobs == 0 {
+ require.JSONEq(t, "[]",
string(payload.Statistics[0].Blobs))
+ } else {
+ var blobs []struct {
+ Fields json.RawMessage `json:"fields"`
+ }
+ require.NoError(t,
json.Unmarshal(payload.Statistics[0].Blobs, &blobs))
+ require.Len(t, blobs, 1)
+ require.JSONEq(t, "[]", string(blobs[0].Fields))
+ }
+
+ parsed, err := ParseMetadataBytes(encoded)
+ require.NoError(t, err)
+ registered := slices.Collect(parsed.Statistics())
+ require.Len(t, registered, 1)
+ require.Len(t, registered[0].BlobMetadata, tc.wantBlobs)
+ if tc.wantBlobs == 1 {
+ require.Empty(t,
registered[0].BlobMetadata[0].Fields)
+ }
+ })
+ }
+}
+
+func TestTransactionSetStatisticsOmitsUnsetBlobProperties(t *testing.T) {
+ tbl := inspectTestTable(Identifier{"db", "stats"},
map[string]SnapshotRef{
+ MainBranch: {SnapshotID: 103, Type: BranchRef},
+ })
+ tx := tbl.NewTransaction()
+ stats := testStatisticsRegistrationFile()
+ stats.BlobMetadata[0].Type = "vendor-stats-v1"
+ stats.BlobMetadata[0].Fields = []int32{}
+ stats.BlobMetadata[0].Properties = nil
+
+ require.NoError(t, tx.SetStatistics(stats))
+ meta, err := tx.meta.Build()
+ require.NoError(t, err)
+ encoded, err := json.Marshal(meta)
+ require.NoError(t, err)
+
+ var payload struct {
+ Statistics []struct {
+ Blobs []map[string]json.RawMessage
`json:"blob-metadata"`
+ } `json:"statistics"`
+ }
+ require.NoError(t, json.Unmarshal(encoded, &payload))
+ require.Len(t, payload.Statistics, 1)
+ require.Len(t, payload.Statistics[0].Blobs, 1)
+ require.NotContains(t, payload.Statistics[0].Blobs[0], "properties",
+ "optional properties must be omitted, not encoded as null")
+
+ parsed, err := ParseMetadataBytes(encoded)
+ require.NoError(t, err)
+ require.Equal(t, []StatisticsFile{stats},
slices.Collect(parsed.Statistics()))
+}
diff --git a/website/src/feature-status.md b/website/src/feature-status.md
index c4c732521..4d538fcd1 100644
--- a/website/src/feature-status.md
+++ b/website/src/feature-status.md
@@ -60,7 +60,7 @@ All V1 features are supported. V1 is the format-version
baseline.
| Geography: read, write, and schema | Supported (bounds/pruning intentionally
omitted for geodesic safety) |
| Geometry / Geography: nested-geo null counts | Supported (like other nested
fields, no bounds are written) |
| Multi-argument transforms | Infrastructure present; no concrete
implementations exercised yet |
-| Statistics files (read / round-trip) | Supported |
+| Statistics files (read / round-trip / register / remove) | Supported |
| NDV (Theta-sketch) statistics computation | Roadmap
([#1929](https://github.com/apache/iceberg-go/issues/1929)) |
## FileSystem support