mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* 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>
151 lines
4.7 KiB
Go
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
|
|
}
|