Files
seaweedfs/weed/s3api/s3tables/iceberg_manifest.go
T
dependabot[bot]andChris Lu 2d4b730a2f 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>
2026-09-07 15:58:24 -07:00

194 lines
6.9 KiB
Go

package s3tables
import (
"bytes"
"time"
"github.com/apache/iceberg-go"
"github.com/google/uuid"
)
const secondsPerDay = int64(24 * 60 * 60)
// ReadManifest parses an Iceberg manifest and converts its partition values
// while the manifest's own schema still describes them.
//
// iceberg-go converts what the Avro decoder returned for a logical type - a
// time.Time for a date, a time.Duration for a time - to an Iceberg value
// lazily, on the first Partition() call, using the logical types it read from
// the manifest being parsed. ManifestWriter.addEntry rebinds those to the
// logical types of the manifest it is about to write before it makes that
// call, so an entry that is read and written again without anyone looking at
// its partition converts against the wrong schema. A day partition is where
// the two disagree: iceberg-go's day transform reports an int32 result type,
// so the manifest it writes has no date logical type for that field, nothing
// converts, and the time.Time reaches the encoder as "cannot use time.Time
// with Avro type int".
//
// What survives that is a value iceberg-go never recognized on read either: it
// takes a partition field's logical type from the last branch of its Avro
// union, so a writer that spells an optional partition [<type>, null] rather
// than [null, <type>] hides it, and a time partition is then worse than a
// failed write - time.Duration converts to int64 nanoseconds and silently
// records the wrong value. normalizePartitionValue puts those back.
//
// specs maps partition spec ID to spec the way the table metadata records them.
func ReadManifest(m iceberg.ManifestFile, manifest []byte, discardDeleted bool, specs map[int]iceberg.PartitionSpec, schema *iceberg.Schema) ([]iceberg.ManifestEntry, error) {
entries, err := iceberg.ReadManifest(m, bytes.NewReader(manifest), discardDeleted)
if err != nil {
return nil, err
}
var partitionFields []iceberg.NestedField
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 i, entry := range entries {
partition := entry.DataFile().Partition()
changed := false
for _, field := range partitionFields {
value, ok := partition[field.ID]
if !ok {
continue
}
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.
func normalizePartitionValue(value any, fieldType iceberg.Type) (any, bool) {
switch v := value.(type) {
case time.Time:
utc := v.UTC()
switch fieldType.(type) {
case iceberg.DateType, iceberg.Int32Type:
// A day transform reports an int32 result type and an identity
// transform on a date column reports date; both hold days.
midnight := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
return iceberg.Date(midnight.Unix() / secondsPerDay), true
case iceberg.TimestampType, iceberg.TimestampTzType:
return iceberg.Timestamp(utc.UnixMicro()), true
case iceberg.TimestampNsType, iceberg.TimestampTzNsType:
return iceberg.TimestampNano(utc.UnixNano()), true
}
case time.Duration:
if _, ok := fieldType.(iceberg.TimeType); ok {
return iceberg.Time(v.Microseconds()), true
}
case [16]byte:
if _, ok := fieldType.(iceberg.UUIDType); ok {
return uuid.UUID(v), true
}
}
return nil, false
}