Files
seaweedfs/weed/worker/tasks/iceberg/operations_test.go
T
Dmitriy PavlovandChris Lu 1d0b97f4c6 avro: field time.Time <> iceberg.date (#11091)
* iceberg: normalize foreign day partitions during manifest rewrite

* test: cover manifest rewrite with foreign day partitions

* iceberg: restore every foreign partition value, not just day transforms

iceberg-go 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>] leaves the value as whatever the Avro decoder
produced. A day or date partition then arrives as a time.Time the manifest
writer cannot encode, and a time partition is worse: time.Duration converts
to int64 nanoseconds and silently records the wrong value.

ReadManifest sits next to ReadManifestList, the other shim for what foreign
writers put on the wire, and converts each partition value back to the
Iceberg representation for its field type.

Claude-Session: https://claude.ai/code/session_01FdQyRuWF9SuCnPn21iH9yR

* iceberg: read manifests that carry partition values through the shim

Compaction, delete rewrite and their detection passes read entries and write
the same partition values back into new manifests, so they fail on a foreign
day partition exactly as manifest rewrite does. Where filters see it too:
literalMatchesActual falls through to fmt.Sprint, so a time.Time renders as a
timestamp and never matches the day the user asked for.

The two remaining readers, orphan collection and the admin preview, only look
at file paths and stay on iceberg.ReadManifest.

Claude-Session: https://claude.ai/code/session_01FdQyRuWF9SuCnPn21iH9yR

* iceberg: convert partition values before the writer rebinds logical types

Dimonyga checked the manifests of a live Doris table: every input spells the
partition union null-first, with the date logical type present, so the union
ordering is not what breaks the merge.

The conversion is lazy. iceberg-go converts what the Avro decoder returned on
the first Partition() call, using the logical types read from the manifest
being parsed, and ManifestWriter.addEntry rebinds them to the manifest it is
about to write before it makes that call. A day partition is where the two
disagree -- iceberg-go's day transform reports an int32 result type, so the
manifest it writes carries no date logical type at all -- and an entry nobody
looked at in between converts against that and keeps its time.Time.

That is why only rewrite_manifests failed: compaction and delete rewrite group
entries by partitionKey(df.Partition()) first, which converts them, and a where
filter does the same. Reading every entry's partition here converts them all
while the manifest's own logical types are still in place.

Claude-Session: https://claude.ai/code/session_01FdQyRuWF9SuCnPn21iH9yR

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-09-02 17:24:23 -07:00

152 lines
5.5 KiB
Go

package iceberg
import (
"bytes"
"context"
"fmt"
"path"
"testing"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
)
// Merging manifests a foreign producer wrote used to fail for good on a
// day-partitioned table, whichever way round the producer spelled the
// partition union: nothing between reading an entry and writing it looks at
// its partition, so the time.Time the Avro decoder produced is still there
// when the manifest writer, which has no date logical type for a day
// partition, tries to encode it.
func TestRewriteManifestsNormalizesForeignDayPartitions(t *testing.T) {
for _, valueFirst := range []bool{true, false} {
name := "null-first partition union"
if valueFirst {
name = "value-first partition union"
}
t.Run(name, func(t *testing.T) {
rewriteForeignDayPartitions(t, valueFirst)
})
}
}
func rewriteForeignDayPartitions(t *testing.T, valueFirst bool) {
t.Helper()
fs, client := startFakeFiler(t)
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "event_time", Type: iceberg.PrimitiveTypes.Timestamp, Required: true},
)
spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
SourceIDs: []int{1}, FieldID: 1000, Name: "event_time_day", Transform: iceberg.DayTransform{},
})
setup := tableSetup{
BucketName: "test-bucket",
Namespace: "analytics",
TableName: "events",
Schema: schema,
Spec: &spec,
Snapshots: []table.Snapshot{{
SnapshotID: 1,
TimestampMs: time.Now().UnixMilli(),
ManifestList: "metadata/snap-1.avro",
}},
}
meta := populateTable(t, fs, setup)
metaDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "metadata")
wantDays := make(map[string]iceberg.Date)
var manifests []iceberg.ManifestFile
for i := 0; i < 3; i++ {
day := iceberg.Date(20737 + i)
filePath := setup.fileRef("data", fmt.Sprintf("foreign-%d.parquet", i))
dfBuilder, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentData, filePath, iceberg.ParquetFile,
map[int]any{1000: day}, nil, nil, 1, 1)
if err != nil {
t.Fatalf("build data file %d: %v", i, err)
}
snapshotID := int64(1)
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapshotID, nil, nil, dfBuilder.Build())
manifestName := fmt.Sprintf("foreign-manifest-%d.avro", i)
foreignBytes, manifest := s3tablestest.ForeignPartitionManifest(t, schema, spec, entry,
setup.fileRef("metadata", manifestName), "date", time.Unix(int64(day)*24*60*60, 0).UTC(), valueFirst)
fs.putEntry(metaDir, manifestName, &filer_pb.Entry{
Name: manifestName, Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()}, Content: foreignBytes,
})
manifests = append(manifests, manifest)
wantDays[filePath] = day
}
var manifestList bytes.Buffer
seqNum := int64(1)
if err := iceberg.WriteManifestList(meta.Version(), &manifestList, 1, nil, &seqNum, 0, manifests); err != nil {
t.Fatalf("write manifest list: %v", err)
}
fs.putEntry(metaDir, "snap-1.avro", &filer_pb.Entry{
Name: "snap-1.avro", Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()}, Content: manifestList.Bytes(),
})
result, _, err := NewHandler(nil).rewriteManifests(context.Background(), client, setup.BucketName, setup.tablePath(), Config{
MinManifestsToRewrite: 3,
MaxCommitRetries: 3,
})
if err != nil {
t.Fatalf("rewriteManifests failed for foreign day partitions: %v", err)
}
if result != "rewrote 3 manifests into 1 (3 entries)" {
t.Fatalf("rewriteManifests result = %q, want 3 manifests merged", result)
}
// The merged manifest has to carry the days the foreign manifests held.
// iceberg-go writes a day partition as a bare Avro int, without the date
// logical type, so it reads back as int32 rather than iceberg.Date.
got := make(map[string]any)
for _, mf := range currentManifests(t, client, setup) {
manifestData, err := loadFileByIcebergPath(context.Background(), client, setup.BucketName, setup.tablePath(), mf.FilePath())
if err != nil {
t.Fatalf("load merged manifest %s: %v", mf.FilePath(), err)
}
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
if err != nil {
t.Fatalf("parse merged manifest %s: %v", mf.FilePath(), err)
}
for _, entry := range entries {
got[entry.DataFile().FilePath()] = entry.DataFile().Partition()[1000]
}
}
if len(got) != len(wantDays) {
t.Fatalf("merged manifests hold %d entries, want %d", len(got), len(wantDays))
}
for filePath, day := range wantDays {
if got[filePath] != int32(day) {
t.Errorf("%s partition = %#v (%T), want %d", filePath, got[filePath], got[filePath], day)
}
}
}
// currentManifests reads the manifests of the table's current snapshot.
func currentManifests(t *testing.T, client filer_pb.SeaweedFilerClient, setup tableSetup) []iceberg.ManifestFile {
t.Helper()
state, err := loadCurrentMetadata(context.Background(), client, setup.BucketName, setup.tablePath())
if err != nil {
t.Fatalf("reload metadata: %v", err)
}
snapshot := state.Metadata.CurrentSnapshot()
if snapshot == nil {
t.Fatal("table has no current snapshot")
}
manifestListData, err := loadFileByIcebergPath(context.Background(), client, setup.BucketName, setup.tablePath(), snapshot.ManifestList)
if err != nil {
t.Fatalf("load manifest list: %v", err)
}
manifests, err := s3tables.ReadManifestList(manifestListData)
if err != nil {
t.Fatalf("parse manifest list: %v", err)
}
return manifests
}