build(deps): bump github.com/twmb/avro from 1.7.2 to 1.8.0 (#11210)

* build(deps): bump github.com/twmb/avro from 1.7.2 to 1.8.0

Bumps [github.com/twmb/avro](https://github.com/twmb/avro) from 1.7.2 to 1.8.0.
- [Commits](https://github.com/twmb/avro/compare/v1.7.2...v1.8.0)

---
updated-dependencies:
- dependency-name: github.com/twmb/avro
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* iceberg: adapt to twmb/avro v1.8.0 and iceberg-go defensive copies

avro v1.8.0 changes Schema.Root() to return *SchemaNode, which breaks
iceberg-go v0.6.0's internal avro_schemas.go. The fix (apache/iceberg-go#1843)
is only on iceberg-go's main branch, unreleased, so bump iceberg-go to
that commit (c210509) alongside the avro bump.

That iceberg-go revision also changes two behaviors seaweedfs worked
around:

- It now infers a manifest list's format version from the embedded
  writer schema, so a list missing the "format-version" header entry
  (DuckDB's shape is read as v2, not v1. ReadManifestList's header
  patching is now a redundant safety net; tests updated to expect v2.

- It returns defensive copies from DataFile.Partition(), so the
  ReadManifest shim's in-place partition normalization was silently
  discarded. Rebuild the entry through NewDataFileBuilder when any
  partition value is normalized, copying every other DataFile field so
  manifest round-trips are preserved.

- It converts day-transform partitions to iceberg.Date on read
  (applyDayTransformDates), so the day-partition cases the shim and
  tests guarded now convert without help; tests updated to expect
  iceberg.Date from the raw read.
EOF
)

* iceberg: accept assert-ref-snapshot-id without snapshot-id

iceberg-go's new nullableInt64 parser rejects an assert-ref-snapshot-id
requirement whose "snapshot-id" field is absent from the JSON, even
though the Iceberg REST spec makes it optional (null means the ref must
not already exist). v0.6.0 used a plain *int64, so absent was nil and
accepted. ClickHouse sends the requirement without snapshot-id when
asserting a branch does not yet exist, so its writes fail with
"missing required field \"snapshot-id\"".

normalizeRequirements splices an explicit null into any
assert-ref-snapshot-id requirement missing the field before handing
the JSON to iceberg-go's parser, restoring the v0.6.0 behavior across
both iceberg-go versions.

* iceberg: fix v1 block_size_in_bytes default in rebuilt manifest entries

rebuildManifestEntry set block_size_in_bytes to 0, but the v1 manifest
schema requires the default of 64 MiB ("Always write default in v1").
The original value is not exposed on the DataFile interface, so use the
spec default. Also clarify the fallback comment to note that empty
(zero-record / zero-byte) files also trigger it, not just a nil spec.

Add a round-trip test that writes a rebuilt entry as v1 and verifies
block_size_in_bytes is 64 MiB via Avro decoding.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
This commit is contained in:
dependabot[bot]andChris Lu authored and GitHub committed 2026-09-07 15:58:24 -07:00
1 parent 005012edcf
commit 2d4b730a2f
11 files changed
+504 -151

No files matched your search

+6 -1
View File
@@ -51,7 +51,12 @@ func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
req.Identifier = raw.Identifier
var statisticsUpdates []statisticsUpdate
if len(raw.Requirements) > 0 {
if err := json.Unmarshal(raw.Requirements, &req.Requirements); err != nil {
normalized, err := normalizeRequirements(raw.Requirements)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid requirements: "+err.Error())
return
}
if err := json.Unmarshal(normalized, &req.Requirements); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid requirements: "+err.Error())
return
}
+5 -1
View File
@@ -246,7 +246,11 @@ func cloneTableMetadata(m *s3tables.TableMetadata) *s3tables.TableMetadata {
func parseTableChange(change tableChangeRequest) (table.Requirements, table.Updates, []statisticsUpdate, *icebergRequestError) {
var requirements table.Requirements
if len(change.Requirements) > 0 {
if err := json.Unmarshal(change.Requirements, &requirements); err != nil {
normalized, err := normalizeRequirements(change.Requirements)
if err != nil {
return nil, nil, nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Invalid requirements: " + err.Error()}
}
if err := json.Unmarshal(normalized, &requirements); err != nil {
return nil, nil, nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Invalid requirements: " + err.Error()}
}
}
+55
View File
@@ -0,0 +1,55 @@
package iceberg
import (
"encoding/json"
)
// normalizeRequirements repairs requirement JSON that iceberg-go's parser
// rejects but the Iceberg REST spec permits.
//
// iceberg-go's assert-ref-snapshot-id parser uses a nullableInt64 that tracks
// whether the "snapshot-id" field was present in the JSON. An absent field
// (set == false) is rejected as "missing required field", even though the spec
// makes snapshot-id optional — null means the ref must not already exist.
// iceberg-go v0.6.0 used a plain *int64, so absent was nil and accepted.
//
// Clients like ClickHouse send assert-ref-snapshot-id without snapshot-id when
// asserting a branch does not yet exist. Splicing in an explicit null before
// unmarshaling restores the v0.6.0 behavior across both iceberg-go versions.
func normalizeRequirements(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return raw, nil
}
var reqs []json.RawMessage
if err := json.Unmarshal(raw, &reqs); err != nil {
return raw, err
}
changed := false
for i, req := range reqs {
var obj map[string]json.RawMessage
if err := json.Unmarshal(req, &obj); err != nil {
continue // let the downstream parser report the error
}
var typ string
if rawType, ok := obj["type"]; ok {
_ = json.Unmarshal(rawType, &typ)
}
if typ != "assert-ref-snapshot-id" {
continue
}
if _, hasSnapshotID := obj["snapshot-id"]; hasSnapshotID {
continue
}
obj["snapshot-id"] = json.RawMessage("null")
fixed, err := json.Marshal(obj)
if err != nil {
continue
}
reqs[i] = fixed
changed = true
}
if !changed {
return raw, nil
}
return json.Marshal(reqs)
}
+94
View File
@@ -0,0 +1,94 @@
package iceberg
import (
"encoding/json"
"testing"
"github.com/apache/iceberg-go/table"
)
func TestNormalizeRequirementsAddsNullSnapshotID(t *testing.T) {
// ClickHouse sends assert-ref-snapshot-id without snapshot-id when
// asserting a branch does not yet exist. iceberg-go's parser rejects
// an absent field; normalizeRequirements splices in an explicit null.
raw := json.RawMessage(`[{"type":"assert-ref-snapshot-id","ref":"main"}]`)
// Without normalization, iceberg-go rejects the requirement.
var reqs table.Requirements
if err := json.Unmarshal(raw, &reqs); err == nil {
t.Fatal("iceberg-go accepted an assert-ref-snapshot-id without snapshot-id; expected an error")
}
normalized, err := normalizeRequirements(raw)
if err != nil {
t.Fatalf("normalizeRequirements: %v", err)
}
if err := json.Unmarshal(normalized, &reqs); err != nil {
t.Fatalf("unmarshal normalized requirements: %v", err)
}
if len(reqs) != 1 {
t.Fatalf("got %d requirements, want 1", len(reqs))
}
if reqs[0].GetType() != "assert-ref-snapshot-id" {
t.Fatalf("requirement type = %s, want assert-ref-snapshot-id", reqs[0].GetType())
}
}
func TestNormalizeRequirementsPreservesPresentSnapshotID(t *testing.T) {
raw := json.RawMessage(`[{"type":"assert-ref-snapshot-id","ref":"main","snapshot-id":42}]`)
normalized, err := normalizeRequirements(raw)
if err != nil {
t.Fatalf("normalizeRequirements: %v", err)
}
var reqs table.Requirements
if err := json.Unmarshal(normalized, &reqs); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(reqs) != 1 {
t.Fatalf("got %d requirements, want 1", len(reqs))
}
}
func TestNormalizeRequirementsPreservesExplicitNullSnapshotID(t *testing.T) {
raw := json.RawMessage(`[{"type":"assert-ref-snapshot-id","ref":"main","snapshot-id":null}]`)
normalized, err := normalizeRequirements(raw)
if err != nil {
t.Fatalf("normalizeRequirements: %v", err)
}
var reqs table.Requirements
if err := json.Unmarshal(normalized, &reqs); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(reqs) != 1 {
t.Fatalf("got %d requirements, want 1", len(reqs))
}
}
func TestNormalizeRequirementsLeavesOtherTypesUnchanged(t *testing.T) {
raw := json.RawMessage(`[{"type":"assert-create"},{"type":"assert-table-uuid","uuid":"00000000-0000-0000-0000-000000000000"}]`)
normalized, err := normalizeRequirements(raw)
if err != nil {
t.Fatalf("normalizeRequirements: %v", err)
}
var reqs table.Requirements
if err := json.Unmarshal(normalized, &reqs); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(reqs) != 2 {
t.Fatalf("got %d requirements, want 2", len(reqs))
}
}
func TestNormalizeRequirementsEmptyInput(t *testing.T) {
normalized, err := normalizeRequirements(nil)
if err != nil {
t.Fatalf("normalizeRequirements(nil): %v", err)
}
if normalized != nil {
t.Fatalf("expected nil output for nil input, got %s", normalized)
}
}
+106 -4
View File
@@ -40,13 +40,20 @@ func ReadManifest(m iceberg.ManifestFile, manifest []byte, discardDeleted bool,
}
var partitionFields []iceberg.NestedField
if spec, found := specs[int(m.PartitionSpecID())]; found && schema != nil {
if partitionType := spec.PartitionType(schema); partitionType != nil {
partitionFields = partitionType.FieldList
var spec iceberg.PartitionSpec
var specFound bool
if schema != nil {
spec, specFound = specs[int(m.PartitionSpecID())]
if specFound {
if partitionType := spec.PartitionType(schema); partitionType != nil {
partitionFields = partitionType.FieldList
}
}
}
for _, entry := range entries {
for i, entry := range entries {
partition := entry.DataFile().Partition()
changed := false
for _, field := range partitionFields {
value, ok := partition[field.ID]
if !ok {
@@ -54,12 +61,107 @@ func ReadManifest(m iceberg.ManifestFile, manifest []byte, discardDeleted bool,
}
if normalized, ok := normalizePartitionValue(value, field.Type); ok {
partition[field.ID] = normalized
changed = true
}
}
if changed {
// iceberg-go returns a defensive copy from Partition(), so the
// in-place edits above never reach the underlying DataFile.
// Rebuild the entry with a DataFile that carries the normalized
// partition so a subsequent write encodes the Iceberg values.
entries[i] = rebuildManifestEntry(entry, spec, partition)
}
}
return entries, nil
}
// rebuildManifestEntry returns a copy of entry whose DataFile carries the
// given partition map. It is used to persist partition values normalized
// after a read, since iceberg-go's Partition() getter hands back a clone
// rather than the live map. Only the partition changes; every other DataFile
// field is copied through the builder so manifest round-trips are preserved.
func rebuildManifestEntry(entry iceberg.ManifestEntry, spec iceberg.PartitionSpec, partition map[int]any) iceberg.ManifestEntry {
df := entry.DataFile()
builder, err := iceberg.NewDataFileBuilder(
spec,
df.ContentType(),
df.FilePath(),
df.FileFormat(),
partition,
nil, // logical types are derived from the spec at write time
nil,
df.Count(),
df.FileSizeBytes(),
)
if err != nil {
// The original DataFile already validated these fields, so the only
// ways to get here are a nil spec (which rebuildManifestEntry's caller
// guards against) or a zero-record / zero-byte file, which the builder
// rejects with recordCount <= 0 or fileSize <= 0. Fall back to the
// original entry rather than panic; the unnormalized partition survives
// but an empty file is unlikely to carry a logical partition value.
return entry
}
builder.BlockSizeInBytes(64 * 1024 * 1024) // v1 schema default; deprecated and omitted in v2+
if sizes := df.ColumnSizes(); sizes != nil {
builder.ColumnSizes(sizes)
}
if counts := df.ValueCounts(); counts != nil {
builder.ValueCounts(counts)
}
if counts := df.NullValueCounts(); counts != nil {
builder.NullValueCounts(counts)
}
if counts := df.NaNValueCounts(); counts != nil {
builder.NaNValueCounts(counts)
}
if counts := df.DistinctValueCounts(); counts != nil {
builder.DistinctValueCounts(counts)
}
if bounds := df.LowerBoundValues(); bounds != nil {
builder.LowerBoundValues(bounds)
}
if bounds := df.UpperBoundValues(); bounds != nil {
builder.UpperBoundValues(bounds)
}
if key := df.KeyMetadata(); key != nil {
builder.KeyMetadata(key)
}
if offsets := df.SplitOffsets(); offsets != nil {
builder.SplitOffsets(offsets)
}
if ids := df.EqualityFieldIDs(); ids != nil {
builder.EqualityFieldIDs(ids)
}
if id := df.SortOrderID(); id != nil {
builder.SortOrderID(*id)
}
if id := df.FirstRowID(); id != nil {
builder.FirstRowID(*id)
}
if ref := df.ReferencedDataFile(); ref != nil {
builder.ReferencedDataFile(*ref)
}
if off := df.ContentOffset(); off != nil {
builder.ContentOffset(*off)
}
if size := df.ContentSizeInBytes(); size != nil {
builder.ContentSizeInBytes(*size)
}
newEntry := iceberg.NewManifestEntryBuilder(entry.Status(), ptrInt64(entry.SnapshotID()), builder.Build())
if seq := entry.SequenceNum(); seq != -1 {
newEntry.SequenceNum(seq)
}
if fileSeq := entry.FileSequenceNum(); fileSeq != nil {
newEntry.FileSequenceNum(*fileSeq)
}
return newEntry.Build()
}
func ptrInt64(v int64) *int64 { return &v }
// normalizePartitionValue converts one value the Avro decoder returned for a
// logical type to the Iceberg representation the manifest writer expects,
// mirroring what iceberg-go does itself for the unions it does recognize.
@@ -69,19 +69,22 @@ func stripFormatVersion(t *testing.T, data []byte) []byte {
return append(stripped, data[terminator:]...)
}
// DuckDB writes manifest lists with no Avro header metadata, so iceberg-go
// falls back to v1 and every v2 manifest below it fails to parse.
// DuckDB writes manifest lists with no Avro header metadata. iceberg-go now
// infers the format version from the embedded writer schema on its own, so a
// stripped v2 list is read as v2 even without the header entry. ReadManifestList
// still patches the header for older iceberg-go releases and for writers whose
// schema carries none of the version-distinguishing fields.
func TestReadManifestListWithoutFormatVersion(t *testing.T) {
stripped := stripFormatVersion(t, writeManifestList(t, 2))
// Baseline: iceberg-go alone reads the list as v1, which both mislabels
// the delete manifest and makes ReadManifest reject the v2 manifests.
// Baseline: iceberg-go reads the list as v2 by inferring the version from
// the record schema's fields (content / sequence_number were added in v2).
unpatched, err := iceberg.ReadManifestList(bytes.NewReader(stripped))
if err != nil {
t.Fatalf("iceberg.ReadManifestList: %v", err)
}
if got := unpatched[0].Version(); got != 1 {
t.Fatalf("expected iceberg-go to default to v1, got v%d", got)
if got := unpatched[0].Version(); got != 2 {
t.Fatalf("expected iceberg-go to infer v2, got v%d", got)
}
manifests, err := ReadManifestList(stripped)
+85 -9
View File
@@ -7,6 +7,7 @@ import (
"github.com/apache/iceberg-go"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
"github.com/twmb/avro/ocf"
)
// A manifest whose partition union puts the value branch first hides the
@@ -30,6 +31,9 @@ func TestReadManifestNormalizesForeignPartitions(t *testing.T) {
// nullFirst spells the union the way Java and iceberg-rust do, which
// iceberg-go converts on its own once the partition is read.
nullFirst bool
// rawIsNormalized marks transforms iceberg-go now converts itself on
// read (day transforms), so the raw value already matches want.
rawIsNormalized bool
}{
{
name: "day transform written null-first converts on read",
@@ -47,6 +51,10 @@ func TestReadManifestNormalizesForeignPartitions(t *testing.T) {
logicalType: "date",
decoded: day,
want: iceberg.Date(20737),
// iceberg-go now converts day-transform partitions to iceberg.Date
// itself (applyDayTransformDates), so the raw read already yields
// the Iceberg value regardless of union branch ordering.
rawIsNormalized: true,
},
{
name: "identity transform on a date column",
@@ -99,7 +107,7 @@ func TestReadManifestNormalizesForeignPartitions(t *testing.T) {
// the ordering decides whether the entry arrives as an Iceberg value
// or as whatever the Avro decoder produced.
rawWant := any(c.decoded)
if c.nullFirst {
if c.nullFirst || c.rawIsNormalized {
rawWant = c.want
}
raw, err := iceberg.ReadManifest(foreignManifest, bytes.NewReader(foreignBytes), true)
@@ -155,12 +163,14 @@ func TestReadManifestUnknownSpec(t *testing.T) {
}
}
// iceberg-go converts a partition value on the first Partition() call, with
// whatever logical types are installed then, and ManifestWriter.addEntry
// rebinds them to the manifest it is about to write before it makes that call.
// A day partition has no date logical type there, so an entry nobody looked at
// between reading and writing never converts at all -- the failure Doris and
// iceberg-rust tables hit, whose manifests spell the union null-first.
// iceberg-go now converts day-transform partitions to iceberg.Date during read
// (applyDayTransformDates), so an entry nobody looked at between reading and
// writing still carries an Iceberg value and writes cleanly. This used to fail
// because conversion was lazy and ManifestWriter.addEntry rebound the logical
// types to the output manifest — whose day transform carries no date logical
// type — before the first Partition() call, leaving a time.Time that the
// encoder rejected. The shim keeps the round-trip working for the identity
// transforms iceberg-go still does not convert (see TestReadManifestNormalizesForeignPartitions).
func TestReadManifestConvertsBeforeTheWriterRebindsLogicalTypes(t *testing.T) {
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "event_time", Type: iceberg.PrimitiveTypes.Timestamp, Required: true},
@@ -183,9 +193,13 @@ func TestReadManifestConvertsBeforeTheWriterRebindsLogicalTypes(t *testing.T) {
if err != nil {
t.Fatalf("read foreign manifest: %v", err)
}
got := untouched[0].DataFile().Partition()[1000]
if _, ok := got.(iceberg.Date); !ok {
t.Fatalf("iceberg-go now converts day partitions on read; got %T, want iceberg.Date", got)
}
var buf bytes.Buffer
if _, err := iceberg.WriteManifest("metadata/manifest.avro", &buf, 2, spec, schema, snapshotID, untouched); err == nil {
t.Fatal("writing an entry whose partition was never read should still hold a time.Time")
if _, err := iceberg.WriteManifest("metadata/manifest.avro", &buf, 2, spec, schema, snapshotID, untouched); err != nil {
t.Fatalf("write manifest read directly through iceberg-go: %v", err)
}
entries, err := ReadManifest(foreignManifest, foreignBytes, true, specs, schema)
@@ -197,3 +211,65 @@ func TestReadManifestConvertsBeforeTheWriterRebindsLogicalTypes(t *testing.T) {
t.Fatalf("write manifest read through the shim: %v", err)
}
}
// rebuildManifestEntry rebuilds the DataFile through NewDataFileBuilder, which
// has no setter for the original block_size_in_bytes (the field is not exposed
// on DataFile). The v1 manifest schema includes block_size_in_bytes with a
// default of 64 MiB and the spec says "Always write default in v1", so a
// rebuilt entry written as v1 must carry that default — not 0.
func TestReadManifestRebuiltEntryPreservesV1BlockSizeDefault(t *testing.T) {
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "event_date", Type: iceberg.PrimitiveTypes.Date, Required: true},
)
spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
SourceIDs: []int{1}, FieldID: 1000, Name: "part", Transform: iceberg.IdentityTransform{},
})
specs := map[int]iceberg.PartitionSpec{spec.ID(): spec}
dfBuilder, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentData, "data/file.parquet",
iceberg.ParquetFile, map[int]any{1000: iceberg.Date(20737)}, nil, nil, 1, 1)
if err != nil {
t.Fatalf("build data file: %v", err)
}
snapshotID := int64(1)
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapshotID, nil, nil, dfBuilder.Build())
// valueFirst=true spells the union [<type>, null], hiding the logical type
// from iceberg-go so the partition arrives as a time.Time. That triggers
// normalizePartitionValue → rebuildManifestEntry, which is the path that
// sets block_size_in_bytes.
foreignBytes, foreignManifest := s3tablestest.ForeignPartitionManifest(t, schema, spec, entry,
"metadata/foreign-manifest.avro", "date", time.Date(2026, time.October, 11, 0, 0, 0, 0, time.UTC), true)
entries, err := ReadManifest(foreignManifest, foreignBytes, true, specs, schema)
if err != nil {
t.Fatalf("read foreign manifest: %v", err)
}
var buf bytes.Buffer
if _, err := iceberg.WriteManifest("metadata/manifest.avro", &buf, 1, spec, schema, snapshotID, entries); err != nil {
t.Fatalf("write v1 manifest: %v", err)
}
reader, err := ocf.NewReader(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("open v1 manifest: %v", err)
}
var record map[string]any
if err := reader.Decode(&record); err != nil {
t.Fatalf("decode v1 manifest: %v", err)
}
if err := reader.Close(); err != nil {
t.Fatalf("close v1 manifest: %v", err)
}
dataFile, ok := record["data_file"].(map[string]any)
if !ok {
t.Fatalf("data_file = %T, want record", record["data_file"])
}
got, ok := dataFile["block_size_in_bytes"]
if !ok {
t.Fatal("block_size_in_bytes missing from v1 manifest")
}
const want = int64(64 * 1024 * 1024)
if got != want {
t.Fatalf("block_size_in_bytes = %v, want %d", got, want)
}
}