Files
seaweedfs/weed/s3api/s3tables/s3tablestest/foreign_manifest.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

151 lines
4.7 KiB
Go

package s3tablestest
import (
"bytes"
"encoding/json"
"testing"
"github.com/apache/iceberg-go"
"github.com/twmb/avro"
"github.com/twmb/avro/ocf"
)
// ForeignPartitionManifest rewrites a manifest iceberg-go wrote so its
// partition field carries the Avro union a foreign writer emits: [<type>, null]
// rather than [null, <type>]. Both are valid Avro, but iceberg-go reads a
// partition field's logical type from the union's last branch, so the ordering
// decides whether it hands back an Iceberg value or the rich Go value the Avro
// decoder produced.
//
// logicalType is stamped onto the value branch, since iceberg-go writes a day
// partition as a bare int, and decoded replaces the partition value in the
// record, standing in for what the foreign writer encoded. valueFirst orders
// the union [<type>, null] rather than the [null, <type>] Java and iceberg-rust
// emit.
func ForeignPartitionManifest(
t *testing.T,
schema *iceberg.Schema,
spec iceberg.PartitionSpec,
entry iceberg.ManifestEntry,
manifestPath, logicalType string,
decoded any,
valueFirst bool,
) ([]byte, iceberg.ManifestFile) {
t.Helper()
var original bytes.Buffer
manifest, err := iceberg.WriteManifest(manifestPath, &original, 2, spec, schema, 1, []iceberg.ManifestEntry{entry})
if err != nil {
t.Fatalf("write base manifest: %v", err)
}
reader, err := ocf.NewReader(bytes.NewReader(original.Bytes()))
if err != nil {
t.Fatalf("open base manifest: %v", err)
}
metadata := reader.Metadata()
var record map[string]any
if err := reader.Decode(&record); err != nil {
t.Fatalf("decode base manifest: %v", err)
}
if err := reader.Close(); err != nil {
t.Fatalf("close base manifest: %v", err)
}
fieldName := spec.Field(0).Name
var schemaDoc map[string]any
if err := json.Unmarshal(metadata["avro.schema"], &schemaDoc); err != nil {
t.Fatalf("decode manifest schema: %v", err)
}
dataFile := findAvroField(t, schemaDoc, "data_file")
partition := findAvroField(t, dataFile, "partition")
partitionFields, ok := partition["fields"].([]any)
if !ok {
t.Fatalf("partition schema fields have type %T", partition["fields"])
}
for _, rawField := range partitionFields {
field, ok := rawField.(map[string]any)
if !ok || field["name"] != fieldName {
continue
}
fieldType, ok := field["type"].([]any)
if !ok || len(fieldType) != 2 {
t.Fatalf("%s schema type = %#v, want nullable union", fieldName, field["type"])
}
valueType, ok := fieldType[1].(map[string]any)
if !ok {
primitive, primitiveOK := fieldType[1].(string)
if !primitiveOK {
t.Fatalf("%s value branch = %T, want Avro type", fieldName, fieldType[1])
}
valueType = map[string]any{"type": primitive}
}
valueType["logicalType"] = logicalType
if valueFirst {
field["type"] = []any{valueType, "null"}
} else {
field["type"] = []any{"null", valueType}
}
break
}
dataFileRecord, ok := record["data_file"].(map[string]any)
if !ok {
t.Fatalf("decoded data_file = %T, want record", record["data_file"])
}
partitionRecord, ok := dataFileRecord["partition"].(map[string]any)
if !ok {
t.Fatalf("decoded partition = %T, want record", dataFileRecord["partition"])
}
partitionRecord[fieldName] = decoded
foreignSchemaJSON, err := json.Marshal(schemaDoc)
if err != nil {
t.Fatalf("encode foreign manifest schema: %v", err)
}
foreignSchema, err := avro.Parse(string(foreignSchemaJSON))
if err != nil {
t.Fatalf("parse foreign manifest schema: %v", err)
}
userMetadata := make(map[string][]byte)
for key, value := range metadata {
if key != "avro.schema" && key != "avro.codec" {
userMetadata[key] = value
}
}
var foreign bytes.Buffer
writer, err := ocf.NewWriter(&foreign, foreignSchema, ocf.WithSchema(string(foreignSchemaJSON)), ocf.WithMetadata(userMetadata))
if err != nil {
t.Fatalf("create foreign manifest writer: %v", err)
}
if err := writer.Encode(record); err != nil {
t.Fatalf("encode foreign manifest: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close foreign manifest writer: %v", err)
}
return foreign.Bytes(), iceberg.NewManifestFile(2, manifest.FilePath(), int64(foreign.Len()), manifest.PartitionSpecID(), 1).AddedFiles(1).SequenceNum(1, 1).Build()
}
func findAvroField(t *testing.T, record map[string]any, name string) map[string]any {
t.Helper()
fields, ok := record["fields"].([]any)
if !ok {
t.Fatalf("Avro record fields have type %T", record["fields"])
}
for _, rawField := range fields {
field, ok := rawField.(map[string]any)
if ok && field["name"] == name {
fieldType, ok := field["type"].(map[string]any)
if ok {
return fieldType
}
t.Fatalf("Avro field %q type has type %T", name, field["type"])
}
}
t.Fatalf("Avro field %q not found", name)
return nil
}