mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 20:26:45 +00:00
* iceberg: stamp a default name mapping on new tables * iceberg: repair non-compliant manifests at commit * s3tables: verify ClickHouse writes read back through PyIceberg * iceberg: carry the manifest-list content into repaired manifests * iceberg: refresh the default name mapping on schema evolution * iceberg: merge historical names into the refreshed name mapping * iceberg: never fail a commit on repair fallout * iceberg: harden manifest repair against writer dialects * s3tables: keep PyIceberg reader stderr out of row data * iceberg: keep name mappings unambiguous across field id reassignment * iceberg: align existing manifest content metadata with the list entry
214 lines
8.2 KiB
Go
214 lines
8.2 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/apache/iceberg-go"
|
|
"github.com/apache/iceberg-go/table"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestNameMappingFromSchema(t *testing.T) {
|
|
schema := iceberg.NewSchema(0,
|
|
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
|
iceberg.NestedField{ID: 2, Name: "label", Type: iceberg.PrimitiveTypes.String},
|
|
iceberg.NestedField{ID: 3, Name: "tags", Type: &iceberg.ListType{
|
|
ElementID: 4, Element: iceberg.PrimitiveTypes.String,
|
|
}},
|
|
iceberg.NestedField{ID: 5, Name: "attrs", Type: &iceberg.MapType{
|
|
KeyID: 6, KeyType: iceberg.PrimitiveTypes.String,
|
|
ValueID: 7, ValueType: &iceberg.StructType{FieldList: []iceberg.NestedField{
|
|
{ID: 8, Name: "inner", Type: iceberg.PrimitiveTypes.Int32},
|
|
}},
|
|
}},
|
|
)
|
|
|
|
mapping := nameMappingFromSchema(schema)
|
|
got, err := json.Marshal(mapping)
|
|
if err != nil {
|
|
t.Fatalf("marshal mapping: %v", err)
|
|
}
|
|
|
|
want := `[{"names":["id"],"field-id":1},{"names":["label"],"field-id":2},` +
|
|
`{"names":["tags"],"field-id":3,"fields":[{"names":["element"],"field-id":4}]},` +
|
|
`{"names":["attrs"],"field-id":5,"fields":[{"names":["key"],"field-id":6},` +
|
|
`{"names":["value"],"field-id":7,"fields":[{"names":["inner"],"field-id":8}]}]}]`
|
|
if string(got) != want {
|
|
t.Fatalf("mapping = %s\nwant %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRefreshDefaultNameMappingDropsReassignedNames(t *testing.T) {
|
|
// The stored mapping was derived from a schema whose ids were later
|
|
// reassigned (id<->name swapped). The merged mapping must follow the
|
|
// current schema exactly: duplicating names across ids makes Java readers
|
|
// reject the whole mapping.
|
|
schema := iceberg.NewSchema(0,
|
|
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
|
iceberg.NestedField{ID: 2, Name: "name", Type: iceberg.PrimitiveTypes.String},
|
|
)
|
|
metadata, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil,
|
|
iceberg.Properties{table.DefaultNameMappingKey: `[{"names":["name"],"field-id":1},{"names":["id"],"field-id":2}]`})
|
|
if err != nil {
|
|
t.Fatalf("newTableMetadata: %v", err)
|
|
}
|
|
raw, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
t.Fatalf("marshal metadata: %v", err)
|
|
}
|
|
|
|
refreshed := refreshDefaultNameMapping(raw, metadata)
|
|
final, err := table.ParseMetadataBytes(refreshed)
|
|
if err != nil {
|
|
t.Fatalf("parse refreshed metadata: %v", err)
|
|
}
|
|
var mapping iceberg.NameMapping
|
|
if err := json.Unmarshal([]byte(final.Properties()[table.DefaultNameMappingKey]), &mapping); err != nil {
|
|
t.Fatalf("parse refreshed mapping: %v", err)
|
|
}
|
|
seen := map[string]int{}
|
|
for _, field := range mapping {
|
|
for _, name := range field.Names {
|
|
if prior, dup := seen[name]; dup {
|
|
t.Fatalf("name %q mapped to both field %d and field %d: %s",
|
|
name, prior, field.ID(), final.Properties()[table.DefaultNameMappingKey])
|
|
}
|
|
seen[name] = field.ID()
|
|
}
|
|
}
|
|
if seen["id"] != 1 || seen["name"] != 2 {
|
|
t.Fatalf("current schema names must win: %s", final.Properties()[table.DefaultNameMappingKey])
|
|
}
|
|
}
|
|
|
|
// evolveMetadataSchema returns serialized metadata whose schema renamed
|
|
// "label" to "tag" and gained an "extra" column while the properties still
|
|
// carry the creation-time mapping, simulating a schema-evolution commit.
|
|
func evolveMetadataSchema(t *testing.T, base table.Metadata) []byte {
|
|
t.Helper()
|
|
|
|
raw, err := json.Marshal(base)
|
|
if err != nil {
|
|
t.Fatalf("marshal base metadata: %v", err)
|
|
}
|
|
evolved := iceberg.NewSchema(0,
|
|
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
|
iceberg.NestedField{ID: 2, Name: "tag", Type: iceberg.PrimitiveTypes.String},
|
|
iceberg.NestedField{ID: 3, Name: "extra", Type: iceberg.PrimitiveTypes.String},
|
|
)
|
|
schemaJSON, err := json.Marshal(evolved)
|
|
if err != nil {
|
|
t.Fatalf("marshal evolved schema: %v", err)
|
|
}
|
|
var metadata map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &metadata); err != nil {
|
|
t.Fatalf("unmarshal metadata: %v", err)
|
|
}
|
|
metadata["schemas"] = json.RawMessage("[" + string(schemaJSON) + "]")
|
|
edited, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
t.Fatalf("marshal edited metadata: %v", err)
|
|
}
|
|
return edited
|
|
}
|
|
|
|
func TestRefreshDefaultNameMappingOnSchemaEvolution(t *testing.T) {
|
|
schema := iceberg.NewSchema(0,
|
|
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
|
iceberg.NestedField{ID: 2, Name: "label", Type: iceberg.PrimitiveTypes.String},
|
|
)
|
|
base, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newTableMetadata: %v", err)
|
|
}
|
|
|
|
edited := evolveMetadataSchema(t, base)
|
|
updated, err := table.ParseMetadataBytes(edited)
|
|
if err != nil {
|
|
t.Fatalf("parse evolved metadata: %v", err)
|
|
}
|
|
|
|
refreshed := refreshDefaultNameMapping(edited, updated)
|
|
final, err := table.ParseMetadataBytes(refreshed)
|
|
if err != nil {
|
|
t.Fatalf("parse refreshed metadata: %v", err)
|
|
}
|
|
var mapping iceberg.NameMapping
|
|
if err := json.Unmarshal([]byte(final.Properties()[table.DefaultNameMappingKey]), &mapping); err != nil {
|
|
t.Fatalf("parse refreshed mapping: %v", err)
|
|
}
|
|
if len(mapping) != 3 || mapping[2].ID() != 3 || mapping[2].Names[0] != "extra" {
|
|
t.Fatalf("mapping was not refreshed for the evolved schema: %s", final.Properties()[table.DefaultNameMappingKey])
|
|
}
|
|
// The renamed column keeps its historical name so files written under the
|
|
// old physical name stay resolvable.
|
|
if mapping[1].ID() != 2 || !slices.Contains(mapping[1].Names, "tag") || !slices.Contains(mapping[1].Names, "label") {
|
|
t.Fatalf("renamed column lost a name: %s", final.Properties()[table.DefaultNameMappingKey])
|
|
}
|
|
}
|
|
|
|
func TestRefreshDefaultNameMappingKeepsUserNames(t *testing.T) {
|
|
schema := iceberg.NewSchema(0,
|
|
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
|
iceberg.NestedField{ID: 2, Name: "label", Type: iceberg.PrimitiveTypes.String},
|
|
)
|
|
base, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil,
|
|
iceberg.Properties{table.DefaultNameMappingKey: `[{"names":["custom"],"field-id":1}]`})
|
|
if err != nil {
|
|
t.Fatalf("newTableMetadata: %v", err)
|
|
}
|
|
|
|
edited := evolveMetadataSchema(t, base)
|
|
updated, err := table.ParseMetadataBytes(edited)
|
|
if err != nil {
|
|
t.Fatalf("parse evolved metadata: %v", err)
|
|
}
|
|
|
|
refreshed := refreshDefaultNameMapping(edited, updated)
|
|
final, err := table.ParseMetadataBytes(refreshed)
|
|
if err != nil {
|
|
t.Fatalf("parse refreshed metadata: %v", err)
|
|
}
|
|
var mapping iceberg.NameMapping
|
|
if err := json.Unmarshal([]byte(final.Properties()[table.DefaultNameMappingKey]), &mapping); err != nil {
|
|
t.Fatalf("parse refreshed mapping: %v", err)
|
|
}
|
|
// The user's alias survives the merge alongside the schema name.
|
|
if mapping[0].ID() != 1 || !slices.Contains(mapping[0].Names, "custom") || !slices.Contains(mapping[0].Names, "id") {
|
|
t.Fatalf("user alias was dropped: %s", final.Properties()[table.DefaultNameMappingKey])
|
|
}
|
|
}
|
|
|
|
func TestCreateTableSetsDefaultNameMapping(t *testing.T) {
|
|
// Request ids are deliberately non-sequential: the metadata constructor
|
|
// reassigns fresh ids, and the stamped mapping must follow the final
|
|
// schema, not the request.
|
|
schema := iceberg.NewSchema(0,
|
|
iceberg.NestedField{ID: 7, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
|
iceberg.NestedField{ID: 3, Name: "label", Type: iceberg.PrimitiveTypes.String},
|
|
)
|
|
metadata, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newTableMetadata: %v", err)
|
|
}
|
|
mappingJSON, ok := metadata.Properties()[table.DefaultNameMappingKey]
|
|
if !ok {
|
|
t.Fatalf("newTableMetadata did not set %s: %v", table.DefaultNameMappingKey, metadata.Properties())
|
|
}
|
|
var mapping iceberg.NameMapping
|
|
if err := json.Unmarshal([]byte(mappingJSON), &mapping); err != nil {
|
|
t.Fatalf("parse stamped mapping: %v", err)
|
|
}
|
|
finalSchema := metadata.CurrentSchema()
|
|
if len(mapping) != len(finalSchema.Fields()) {
|
|
t.Fatalf("mapping has %d entries, schema has %d", len(mapping), len(finalSchema.Fields()))
|
|
}
|
|
for i, field := range finalSchema.Fields() {
|
|
if mapping[i].ID() != field.ID || mapping[i].Names[0] != field.Name {
|
|
t.Fatalf("mapping entry %d = %v, want id %d name %s", i, mapping[i], field.ID, field.Name)
|
|
}
|
|
}
|
|
}
|