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 0bc8d2f72 perf(manifest): avoid reflection when cloning DataFile Avro
fields (#2202)
0bc8d2f72 is described below
commit 0bc8d2f727e73948779863d406ec9b8841b038cc
Author: Minh Vu <[email protected]>
AuthorDate: Mon Oct 19 20:08:19 2026 +0200
perf(manifest): avoid reflection when cloning DataFile Avro fields (#2202)
* perf(manifest): avoid reflection when cloning DataFile Avro fields
Signed-off-by: Minh Vu <[email protected]>
* test(manifest): cover shallow Avro clones and all format versions
Signed-off-by: Minh Vu <[email protected]>
* test(manifest): benchmark batch Avro entry serialization
* style(manifest): use reflect type field iterator
---------
Signed-off-by: Minh Vu <[email protected]>
---
data_file_codec.go | 73 +++++++++++++++++--------------------
data_file_codec_bench_test.go | 34 ++++++++++++++++++
data_file_codec_test.go | 84 +++++++++++++++++++++++++++++++++++--------
manifest_projection_test.go | 9 ++---
4 files changed, 140 insertions(+), 60 deletions(-)
diff --git a/data_file_codec.go b/data_file_codec.go
index ea0b96ba6..b11fec574 100644
--- a/data_file_codec.go
+++ b/data_file_codec.go
@@ -19,7 +19,6 @@ package iceberg
import (
"fmt"
- "reflect"
"strconv"
"strings"
@@ -72,10 +71,9 @@ type AvroEntryMarshaler interface {
// MarshalAvroEntry encodes this DataFile as Avro bytes using the
// manifest-entry encoding for the given partition spec, table schema
// and format version (1, 2, or 3). The wire format is the same one a
-// manifest carries for this data file, so adding a field to the
-// underlying struct (and its avro tags) automatically extends what
-// MarshalAvroEntry transports — there is no separate wire-mirror
-// struct to keep in sync.
+// manifest carries for this data file. The Avro tags define the wire fields;
+// TestCloneDataFileAvroFieldsCopiesOnlyAvroFields checks that the explicit
+// clone list stays synchronized when new fields are added.
//
// MarshalAvroEntry is the iceberg-package side of the
// [github.com/apache/iceberg-go/codec] DataFile codec; callers
@@ -183,45 +181,38 @@ func newDecodeEntry(version int) (any, *dataFile) {
return &manifestEntry{Data: df}, df
}
-var dataFileAvroFieldIndexes = avroFieldIndexes(reflect.TypeFor[dataFile]())
-
-func avroFieldIndexes(t reflect.Type) []int {
- indexes := make([]int, 0, t.NumField())
- for i := range t.NumField() {
- if _, hasAvroTag := t.Field(i).Tag.Lookup("avro"); hasAvroTag {
- indexes = append(indexes, i)
- }
- }
-
- return indexes
-}
-
-// cloneDataFileAvroFields returns a fresh *dataFile populated with src's
-// avro-tagged fields. Internal state (sync.Once, lazy-init caches,
-// specID, the field-id lookup maps) is intentionally left at zero
-// values because the avro encoder reads only the avro-tagged fields.
+// cloneDataFileAvroFields shallow-copies the Avro-tagged fields without
+// reflection. Internal state, including sync.Once, caches, and spec IDs, is
+// intentionally left at zero values. The test checks every Avro-tagged field
+// so that new fields cannot be silently omitted from this list.
//
-// The avro-tagged field indexes are discovered once when the package is
-// initialized. This keeps dataFile as the single source of truth for the
-// wire shape while avoiding a reflect.Type and StructTag lookup for every
-// encoded manifest entry. It also sidesteps the go-vet copies-lock warning
-// that would fire on a struct-literal copy of *dataFile (it embeds sync.Once).
-//
-// Note: this is a shallow copy. Pointer-typed avro fields (ColSizes,
-// LowerBounds, etc.) share their backing storage with the source.
-// The no-mutation guarantee of MarshalAvroEntry depends on the avro
-// encoder being read-only on the values it walks; TestMarshalAvroEntry
-// DoesNotMutate asserts this end-to-end across every avro-tagged
-// field, so a future regression in the encoder surfaces in tests.
+// Pointer-typed fields share backing storage with src. The encoder must not
+// mutate these values (see TestMarshalAvroEntryDoesNotMutateAnyAvroField).
func cloneDataFileAvroFields(src *dataFile) *dataFile {
- out := &dataFile{}
- srcVal := reflect.ValueOf(src).Elem()
- outVal := reflect.ValueOf(out).Elem()
- for _, i := range dataFileAvroFieldIndexes {
- outVal.Field(i).Set(srcVal.Field(i))
+ return &dataFile{
+ Content: src.Content,
+ Path: src.Path,
+ Format: src.Format,
+ PartitionData: src.PartitionData,
+ RecordCount: src.RecordCount,
+ FileSize: src.FileSize,
+ BlockSizeInBytes: src.BlockSizeInBytes,
+ ColSizes: src.ColSizes,
+ ValCounts: src.ValCounts,
+ NullCounts: src.NullCounts,
+ NaNCounts: src.NaNCounts,
+ DistinctCounts: src.DistinctCounts,
+ LowerBounds: src.LowerBounds,
+ UpperBounds: src.UpperBounds,
+ Key: src.Key,
+ Splits: src.Splits,
+ EqualityIDs: src.EqualityIDs,
+ SortOrder: src.SortOrder,
+ FirstRowIDField: src.FirstRowIDField,
+ ReferencedDataFileField: src.ReferencedDataFileField,
+ ContentOffsetField: src.ContentOffsetField,
+ ContentSizeInBytesField: src.ContentSizeInBytesField,
}
-
- return out
}
// avroEncodePartitionData converts an id-keyed partition tuple (carrying
diff --git a/data_file_codec_bench_test.go b/data_file_codec_bench_test.go
index c812c48e0..f7080065a 100644
--- a/data_file_codec_bench_test.go
+++ b/data_file_codec_bench_test.go
@@ -99,3 +99,37 @@ func BenchmarkDataFileSchemaCache(b *testing.B) {
}
}
}
+
+var marshalAvroEntryBatchBenchmarkSink []byte
+
+func BenchmarkMarshalAvroEntryBatch(b *testing.B) {
+ const batchSize = 128
+ for _, version := range []int{1, 2, 3} {
+ b.Run("v"+strconv.Itoa(version)+"/files=128", func(b
*testing.B) {
+ spec, schema, first :=
fullyPopulatedDataFileForCodec(b, version)
+ files := make([]*dataFile, batchSize)
+ files[0] = first.(*dataFile)
+ for i := 1; i < len(files); i++ {
+ _, _, df := fullyPopulatedDataFileForCodec(b,
version)
+ files[i] = df.(*dataFile)
+ }
+ if _, err := files[0].MarshalAvroEntry(spec, schema,
version); err != nil {
+ b.Fatal(err)
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ for _, df := range files {
+ encoded, err :=
df.MarshalAvroEntry(spec, schema, version)
+ if err != nil {
+ b.Fatal(err)
+ }
+ marshalAvroEntryBatchBenchmarkSink =
encoded
+ }
+ }
+ b.StopTimer()
+ b.ReportMetric(float64(batchSize), "files/op")
+ })
+ }
+}
diff --git a/data_file_codec_test.go b/data_file_codec_test.go
index 0e35bcce7..74501ec10 100644
--- a/data_file_codec_test.go
+++ b/data_file_codec_test.go
@@ -217,30 +217,84 @@ func
TestManifestEntrySchemaForDistinguishesSameSpecIDDifferentPartitionTypes(t
}
func TestMarshalAvroEntryDoesNotMutateAnyAvroField(t *testing.T) {
- spec, schema, df := fullyPopulatedDataFileForCodec(t, 2)
- impl := df.(*dataFile)
+ for _, version := range []int{1, 2, 3} {
+ t.Run("v"+strconv.Itoa(version), func(t *testing.T) {
+ spec, schema, df := fullyPopulatedDataFileForCodec(t,
version)
+ impl := df.(*dataFile)
- before := snapshotAvroFields(impl)
- _, err := impl.MarshalAvroEntry(spec, schema, 2)
- require.NoError(t, err)
- after := snapshotAvroFields(impl)
+ before := snapshotAvroFields(impl)
+ _, err := impl.MarshalAvroEntry(spec, schema, version)
+ require.NoError(t, err)
+ after := snapshotAvroFields(impl)
- require.Equal(t, before, after,
- "MarshalAvroEntry must not mutate any avro-tagged field of the
source DataFile, "+
- "including pointer-typed fields whose backing storage
is shared with the clone")
+ require.Equal(t, before, after,
+ "MarshalAvroEntry must not mutate any
avro-tagged field of the source DataFile, "+
+ "including pointer-typed fields whose
backing storage is shared with the clone")
+ })
+ }
}
-func TestDataFileAvroFieldIndexesCoverEveryAvroField(t *testing.T) {
+func TestCloneDataFileAvroFieldsCopiesOnlyAvroFields(t *testing.T) {
+ src := &dataFile{}
typ := reflect.TypeFor[dataFile]()
- want := make([]int, 0, typ.NumField())
+ srcVal := reflect.ValueOf(src).Elem()
+
+ // Give every tagged field a nonzero value, including fields added
later.
for i := range typ.NumField() {
- if _, ok := typ.Field(i).Tag.Lookup("avro"); ok {
- want = append(want, i)
+ field := typ.Field(i)
+ if _, tagged := field.Tag.Lookup("avro"); !tagged {
+ continue
+ }
+
+ value := srcVal.Field(i)
+ switch value.Kind() {
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32,
reflect.Int64:
+ value.SetInt(17)
+ case reflect.Uint, reflect.Uint8, reflect.Uint16,
reflect.Uint32, reflect.Uint64:
+ value.SetUint(17)
+ case reflect.String:
+ value.SetString("sentinel")
+ case reflect.Map:
+ value.Set(reflect.MakeMap(value.Type()))
+ case reflect.Pointer:
+ value.Set(reflect.New(value.Type().Elem()))
+ default:
+ t.Fatalf("unhandled Avro field %s of kind %s",
field.Name, value.Kind())
}
}
- require.Equal(t, want, dataFileAvroFieldIndexes,
- "the precomputed clone indexes must track every avro-tagged
dataFile field")
+ // The clone must not carry state from lazily initialized source data.
+ src.projected = true
+ src.specID = 42
+ src.fieldNameToID = map[string]int{"field": 1}
+ src.initPartition.Do(func() {})
+ src.initColumnStats.Do(func() {})
+
+ cloneVal := reflect.ValueOf(cloneDataFileAvroFields(src)).Elem()
+ for i := range typ.NumField() {
+ field := typ.Field(i)
+ if _, tagged := field.Tag.Lookup("avro"); tagged {
+ source := srcVal.Field(i)
+ cloned := cloneVal.Field(i)
+ require.Equal(t, source.Interface(), cloned.Interface(),
+ "Avro field %s must be copied", field.Name)
+ switch source.Kind() {
+ case reflect.Pointer:
+ require.Same(t, source.Interface(),
cloned.Interface(),
+ "pointer-typed Avro field %s must be
shallow-copied", field.Name)
+ case reflect.Map:
+ // Mutating the source map must be visible
through the clone.
+ // This detects accidental deep copies in this
hot path.
+ key := reflect.Zero(source.Type().Key())
+ source.SetMapIndex(key,
reflect.Zero(source.Type().Elem()))
+ require.True(t, cloned.MapIndex(key).IsValid(),
+ "map-typed Avro field %s must be
shallow-copied", field.Name)
+ }
+ } else {
+ require.True(t, cloneVal.Field(i).IsZero(),
+ "internal field %s must be reset", field.Name)
+ }
+ }
}
func TestMarshalAvroEntryDecimalPartitionRoundTrip(t *testing.T) {
diff --git a/manifest_projection_test.go b/manifest_projection_test.go
index fdfe138af..42e8f9a2d 100644
--- a/manifest_projection_test.go
+++ b/manifest_projection_test.go
@@ -33,10 +33,11 @@ func
TestManifestEntryProjectionWhitelistCoversDataFileSchema(t *testing.T) {
ID: 1, Name: "id", Type: PrimitiveTypes.Int64, Required: true,
})
dataFileType := reflect.TypeFor[dataFile]()
- dataFileFields := make(map[string]struct{},
len(dataFileAvroFieldIndexes))
- for _, index := range dataFileAvroFieldIndexes {
- name := dataFileType.Field(index).Tag.Get("avro")
- dataFileFields[name] = struct{}{}
+ dataFileFields := make(map[string]struct{})
+ for field := range dataFileType.Fields() {
+ if name, ok := field.Tag.Lookup("avro"); ok {
+ dataFileFields[name] = struct{}{}
+ }
}
optionalFields := map[string]struct{}{
"column_sizes": {},