package iceberg import ( "bytes" "context" "encoding/json" "fmt" "strings" "github.com/linkedin/goavro/v2" "github.com/seaweedfs/seaweedfs/weed/filer" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/wdclient" ) // Some engines (ClickHouse's experimental Iceberg writes among them) commit // snapshots whose manifest avro files omit the spec's field-id annotations and // reference files by bucket-relative path. Both make the snapshot unreadable // for other engines even though the data itself is fine. Since the catalog is // the commit chokepoint, add-snapshot updates are repaired here: the manifest // list and manifests are transcoded with the annotations added and the paths // absolutized, and the snapshot is pointed at the repaired copies. // avroFieldIDs describes the spec-mandated ids for one avro field and the // fields nested beneath it (through unions, arrays, and records). type avroFieldIDs struct { id int elementID int children map[string]avroFieldIDs } // manifestListFieldIDs returns the fixed v2 manifest_file schema ids. func manifestListFieldIDs() map[string]avroFieldIDs { return map[string]avroFieldIDs{ "manifest_path": {id: 500}, "manifest_length": {id: 501}, "partition_spec_id": {id: 502}, "content": {id: 517}, "sequence_number": {id: 515}, "min_sequence_number": {id: 516}, "added_snapshot_id": {id: 503}, "added_files_count": {id: 504}, "existing_files_count": {id: 505}, "deleted_files_count": {id: 506}, "added_rows_count": {id: 512}, "existing_rows_count": {id: 513}, "deleted_rows_count": {id: 514}, "partitions": {id: 507, elementID: 508, children: map[string]avroFieldIDs{ "contains_null": {id: 509}, "contains_nan": {id: 518}, "lower_bound": {id: 510}, "upper_bound": {id: 511}, }}, "key_metadata": {id: 519}, "first_row_id": {id: 520}, } } // manifestEntryFieldIDs returns the fixed v2 manifest_entry schema ids. The // partition struct is table-specific; its ids come from the partition-spec // JSON embedded in the manifest's OCF metadata. func manifestEntryFieldIDs(partitionIDs map[string]avroFieldIDs) map[string]avroFieldIDs { return map[string]avroFieldIDs{ "status": {id: 0}, "snapshot_id": {id: 1}, "sequence_number": {id: 3}, "file_sequence_number": {id: 4}, "data_file": {id: 2, children: map[string]avroFieldIDs{ "content": {id: 134}, "file_path": {id: 100}, "file_format": {id: 101}, "partition": {id: 102, children: partitionIDs}, "record_count": {id: 103}, "file_size_in_bytes": {id: 104}, "column_sizes": {id: 108, children: kvFieldIDs(117, 118)}, "value_counts": {id: 109, children: kvFieldIDs(119, 120)}, "null_value_counts": {id: 110, children: kvFieldIDs(121, 122)}, "nan_value_counts": {id: 137, children: kvFieldIDs(138, 139)}, "lower_bounds": {id: 125, children: kvFieldIDs(126, 127)}, "upper_bounds": {id: 128, children: kvFieldIDs(129, 130)}, "key_metadata": {id: 131}, "split_offsets": {id: 132, elementID: 133}, "equality_ids": {id: 135, elementID: 136}, "sort_order_id": {id: 140}, "first_row_id": {id: 142}, "referenced_data_file": {id: 143}, "content_offset": {id: 144}, "content_size_in_bytes": {id: 145}, }}, } } func kvFieldIDs(keyID, valueID int) map[string]avroFieldIDs { return map[string]avroFieldIDs{ "key": {id: keyID}, "value": {id: valueID}, } } // partitionFieldIDsFromSpec extracts name -> field-id from the partition-spec // JSON carried in a manifest's OCF metadata. func partitionFieldIDsFromSpec(specJSON []byte) (map[string]avroFieldIDs, error) { if len(specJSON) == 0 { return map[string]avroFieldIDs{}, nil } var fields []struct { Name string `json:"name"` FieldID int `json:"field-id"` } if err := json.Unmarshal(specJSON, &fields); err != nil { return nil, fmt.Errorf("parse partition-spec metadata: %w", err) } ids := make(map[string]avroFieldIDs, len(fields)) for _, f := range fields { ids[f.Name] = avroFieldIDs{id: f.FieldID} } return ids, nil } // annotateAvroRecordFields adds "field-id" to every field of a parsed avro // record schema, recursing into nested types. Returns whether anything was // added, and ok=false when a field has no known id (the schema is left for an // unknown writer dialect rather than half-annotated). func annotateAvroRecordFields(record map[string]any, ids map[string]avroFieldIDs) (changed, ok bool) { fields, _ := record["fields"].([]any) if len(fields) == 0 { return false, true } for _, f := range fields { field, isMap := f.(map[string]any) if !isMap { return changed, false } name, _ := field["name"].(string) spec, known := ids[name] if !known { return changed, false } if _, has := field["field-id"]; !has { field["field-id"] = spec.id changed = true } typeChanged, typeOK := annotateAvroType(field["type"], spec) changed = changed || typeChanged if !typeOK { return changed, false } } return changed, true } // annotateAvroType annotates the schema node of a single field: unions are // followed through their non-null branches, arrays get "element-id", and // nested records recurse with the field's child ids. func annotateAvroType(node any, spec avroFieldIDs) (changed, ok bool) { switch t := node.(type) { case []any: // union for _, branch := range t { branchChanged, branchOK := annotateAvroType(branch, spec) changed = changed || branchChanged if !branchOK { return changed, false } } return changed, true case map[string]any: switch t["type"] { case "record": childIDs := spec.children if childIDs == nil { childIDs = map[string]avroFieldIDs{} } return annotateAvroRecordFields(t, childIDs) case "array": if spec.elementID != 0 { if _, has := t["element-id"]; !has { t["element-id"] = spec.elementID changed = true } } itemChanged, itemOK := annotateAvroType(t["items"], spec) return changed || itemChanged, itemOK case "map": // The spec wants key-id/value-id on genuine avro maps; this repair // only knows the array-of-key_value encoding, so leave the file to // its writer rather than half-annotating it. return changed, false default: return false, true } default: // primitive name or named-type reference return false, true } } // transcodeManifestAvro decodes an avro OCF file, annotates its schema with // the given ids, applies mutate to each datum and fixMeta to the OCF metadata, // and re-encodes. When nothing changes, the original bytes are returned // unchanged. func transcodeManifestAvro(raw []byte, ids map[string]avroFieldIDs, mutate func(map[string]any) bool, fixMeta func(map[string][]byte) bool) ([]byte, bool, error) { reader, err := goavro.NewOCFReader(bytes.NewReader(raw)) if err != nil { return nil, false, fmt.Errorf("open avro: %w", err) } meta := reader.MetaData() outMeta := make(map[string][]byte, len(meta)) for k, v := range meta { if k == "avro.schema" || k == "avro.codec" { continue } outMeta[k] = v } metaChanged := false if fixMeta != nil { metaChanged = fixMeta(outMeta) } var schema map[string]any if err := json.Unmarshal(meta["avro.schema"], &schema); err != nil { return nil, false, fmt.Errorf("parse avro schema: %w", err) } schemaChanged, ok := annotateAvroRecordFields(schema, ids) if !ok { return nil, false, fmt.Errorf("avro schema has fields outside the Iceberg manifest spec") } datumChanged := false var datums []any for reader.Scan() { datum, err := reader.Read() if err != nil { return nil, false, fmt.Errorf("read avro datum: %w", err) } if record, isMap := datum.(map[string]any); isMap && mutate != nil { if mutate(record) { datumChanged = true } } datums = append(datums, datum) } if err := reader.Err(); err != nil { return nil, false, fmt.Errorf("scan avro: %w", err) } if !schemaChanged && !datumChanged && !metaChanged { return raw, false, nil } annotatedSchema, err := json.Marshal(schema) if err != nil { return nil, false, err } var buf bytes.Buffer writer, err := goavro.NewOCFWriter(goavro.OCFConfig{ W: &buf, Schema: string(annotatedSchema), MetaData: outMeta, }) if err != nil { return nil, false, fmt.Errorf("create avro writer: %w", err) } if err := writer.Append(datums); err != nil { return nil, false, fmt.Errorf("write avro datums: %w", err) } return buf.Bytes(), true, nil } // manifestStore abstracts reading and writing files in a table's metadata // directory so the repair logic is testable without a filer. type manifestStore interface { loadFile(ctx context.Context, location string) ([]byte, error) saveFile(ctx context.Context, location string, data []byte) error } // absolutizeLocation resolves a bucket-relative path against the bucket of the // table location. Locations that already carry a scheme pass through. func absolutizeLocation(location, tableLocation string) string { if location == "" || strings.Contains(location, "://") { return location } bucket, _, err := parseS3Location(tableLocation) if err != nil { return location } return "s3://" + bucket + "/" + strings.TrimPrefix(location, "/") } // metadataFileName returns the file name when location is a direct child of // the table's metadata directory, which is the only place repair will read // from or write to. func metadataFileName(tableLocation, location string) (string, bool) { prefix := strings.TrimSuffix(tableLocation, "/") + "/metadata/" name := strings.TrimPrefix(location, prefix) if name == location || name == "" || strings.Contains(name, "/") { return "", false } return name, true } const repairedManifestPrefix = "repaired-" // maxRepairableManifestSize bounds how much manifest data the repair path // buffers in memory; anything larger fails the repair and the commit // proceeds with the writer's original files. const maxRepairableManifestSize = 64 << 20 // hasFieldIDAnnotations is the cheap compliance probe: spec-compliant writers // annotate every manifest avro schema with field-id, and a writer that // annotates the manifest list annotates its manifests too. func hasFieldIDAnnotations(raw []byte) bool { reader, err := goavro.NewOCFReader(bytes.NewReader(raw)) if err != nil { return false } return bytes.Contains(reader.MetaData()["avro.schema"], []byte(`"field-id"`)) } // repairManifestList loads a snapshot's manifest list and, when it needs // repair, rewrites it (and the manifests it references) into spec-compliant // copies next to the originals. Returns the manifest list location the // snapshot should reference. func repairManifestList(ctx context.Context, store manifestStore, tableLocation, listLocation string) (string, bool, error) { absListLocation := absolutizeLocation(listLocation, tableLocation) listName, inMetadataDir := metadataFileName(tableLocation, absListLocation) if !inMetadataDir { return listLocation, false, nil } listBytes, err := store.loadFile(ctx, absListLocation) if err != nil { return listLocation, false, fmt.Errorf("load manifest list: %w", err) } if absListLocation == listLocation && hasFieldIDAnnotations(listBytes) { return listLocation, false, nil } metadataDir := strings.TrimSuffix(tableLocation, "/") + "/metadata/" var manifestErr error repairedList, listChanged, err := transcodeManifestAvro(listBytes, manifestListFieldIDs(), func(record map[string]any) bool { manifestPath, _ := record["manifest_path"].(string) if manifestPath == "" || manifestErr != nil { return false } absManifest := absolutizeLocation(manifestPath, tableLocation) recordChanged := absManifest != manifestPath if recordChanged { record["manifest_path"] = absManifest } manifestName, ok := metadataFileName(tableLocation, absManifest) if !ok { return recordChanged } manifestBytes, err := store.loadFile(ctx, absManifest) if err != nil { manifestErr = fmt.Errorf("load manifest %s: %w", absManifest, err) return recordChanged } repaired, changed, err := repairManifest(manifestBytes, tableLocation, manifestContentValue(record)) if err != nil { manifestErr = fmt.Errorf("repair manifest %s: %w", absManifest, err) return recordChanged } if !changed { return recordChanged } repairedLocation := metadataDir + repairedManifestPrefix + manifestName if err := store.saveFile(ctx, repairedLocation, repaired); err != nil { manifestErr = fmt.Errorf("save repaired manifest %s: %w", repairedLocation, err) return recordChanged } record["manifest_path"] = repairedLocation record["manifest_length"] = int64(len(repaired)) return true }, nil) if err != nil { return listLocation, false, err } if manifestErr != nil { return listLocation, false, manifestErr } if !listChanged && absListLocation == listLocation { return listLocation, false, nil } repairedListLocation := metadataDir + repairedManifestPrefix + listName if listChanged { if err := store.saveFile(ctx, repairedListLocation, repairedList); err != nil { return listLocation, false, fmt.Errorf("save repaired manifest list: %w", err) } return repairedListLocation, true, nil } // Only the pointer was relative; the file itself is fine. return absListLocation, true, nil } // manifestContentValue reads the manifest-list entry's content field // (0 = data, 1 = deletes); v1 lists have no such field and default to data. // Union-typed values arrive from goavro as a single-branch map. func manifestContentValue(record map[string]any) int { value := record["content"] if union, isUnion := value.(map[string]any); isUnion { for _, branch := range union { value = branch break } } switch v := value.(type) { case int32: return int(v) case int64: return int(v) case int: return v } return 0 } // fixManifestOCFMetadata fixes OCF metadata keys the spec requires of // manifests but lax writers omit or contradict: "content" must agree with the // manifest-list entry, which is what readers trust for scan planning, and // "partition-spec-id" (ClickHouse writes the underscore variant). func fixManifestOCFMetadata(listContent int) func(map[string][]byte) bool { return func(meta map[string][]byte) bool { changed := false content := "data" if listContent == 1 { content = "deletes" } if string(meta["content"]) != content { meta["content"] = []byte(content) changed = true } if _, ok := meta["partition-spec-id"]; !ok { if v, ok := meta["partition_spec_id"]; ok { meta["partition-spec-id"] = v changed = true } } return changed } } // repairManifest annotates a single manifest's schema and absolutizes the // data file paths inside it. listContent is the content declared by the // manifest-list entry referencing this manifest. func repairManifest(raw []byte, tableLocation string, listContent int) ([]byte, bool, error) { reader, err := goavro.NewOCFReader(bytes.NewReader(raw)) if err != nil { return nil, false, fmt.Errorf("open manifest avro: %w", err) } partitionIDs, err := partitionFieldIDsFromSpec(reader.MetaData()["partition-spec"]) if err != nil { return nil, false, err } return transcodeManifestAvro(raw, manifestEntryFieldIDs(partitionIDs), func(record map[string]any) bool { dataFile, _ := record["data_file"].(map[string]any) if dataFile == nil { return false } filePath, _ := dataFile["file_path"].(string) absPath := absolutizeLocation(filePath, tableLocation) if absPath == filePath { return false } dataFile["file_path"] = absPath return true }, fixManifestOCFMetadata(listContent)) } // repairAddSnapshotUpdates rewrites the manifest-list references of // add-snapshot updates whose manifests need repair. Repair is best effort: on // any error the original update is kept and the commit proceeds as it would // have without repair. func repairAddSnapshotUpdates(ctx context.Context, store manifestStore, tableLocation string, rawUpdates []json.RawMessage) ([]json.RawMessage, bool) { changedAny := false out := make([]json.RawMessage, len(rawUpdates)) copy(out, rawUpdates) for i, rawUpdate := range rawUpdates { var probe struct { Action string `json:"action"` } if err := json.Unmarshal(rawUpdate, &probe); err != nil || probe.Action != "add-snapshot" { continue } var update map[string]json.RawMessage if err := json.Unmarshal(rawUpdate, &update); err != nil { continue } var snapshot map[string]json.RawMessage if err := json.Unmarshal(update["snapshot"], &snapshot); err != nil { continue } var listLocation string if err := json.Unmarshal(snapshot["manifest-list"], &listLocation); err != nil || listLocation == "" { continue } repairedLocation, changed, err := repairManifestList(ctx, store, tableLocation, listLocation) if err != nil { glog.Warningf("Iceberg: manifest repair for %s skipped: %v", listLocation, err) continue } if !changed { continue } locationJSON, err := json.Marshal(repairedLocation) if err != nil { continue } snapshot["manifest-list"] = locationJSON snapshotJSON, err := json.Marshal(snapshot) if err != nil { continue } update["snapshot"] = snapshotJSON updateJSON, err := json.Marshal(update) if err != nil { continue } out[i] = updateJSON changedAny = true glog.V(1).Infof("Iceberg: repaired manifest list %s -> %s", listLocation, repairedLocation) } return out, changedAny } // serverManifestStore reads and writes table metadata files through the filer. type serverManifestStore struct { server *Server tableLocation string } func (st *serverManifestStore) split(location string) (bucket, tablePath, fileName string, err error) { fileName, ok := metadataFileName(st.tableLocation, location) if !ok { return "", "", "", fmt.Errorf("%s is outside the table metadata directory", location) } bucket, tablePath, err = parseS3Location(st.tableLocation) return bucket, tablePath, fileName, err } func (st *serverManifestStore) loadFile(ctx context.Context, location string) ([]byte, error) { bucket, tablePath, fileName, err := st.split(location) if err != nil { return nil, err } return st.server.loadTableMetadataBlob(ctx, bucket, tablePath, fileName) } func (st *serverManifestStore) saveFile(ctx context.Context, location string, data []byte) error { bucket, tablePath, fileName, err := st.split(location) if err != nil { return err } return st.server.saveMetadataBlob(ctx, bucket, tablePath, fileName, data, "application/avro", false) } // repairAddSnapshotManifests is the server entry point used by the commit // handlers. func (s *Server) repairAddSnapshotManifests(ctx context.Context, tableLocation string, rawUpdates []json.RawMessage) ([]json.RawMessage, bool) { store := &serverManifestStore{server: s, tableLocation: tableLocation} return repairAddSnapshotUpdates(ctx, store, tableLocation, rawUpdates) } // lookupFileIDAdapter lets filer.StreamContent resolve volume locations // through the iceberg server's filer client. type lookupFileIDAdapter struct { fn wdclient.LookupFileIdFunctionType } func (a *lookupFileIDAdapter) GetLookupFileIdFunction() wdclient.LookupFileIdFunctionType { return a.fn } // loadTableMetadataBlob reads a file from the table's metadata directory, // following chunks for files that were written through the S3 gateway rather // than inline by the catalog itself. func (s *Server) loadTableMetadataBlob(ctx context.Context, bucketName, tablePath, fileName string) ([]byte, error) { var entry *filer_pb.Entry err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{ Directory: metadataDirPath(bucketName, tablePath), Name: fileName, }) if err != nil { return err } entry = resp.Entry return nil }) if err != nil { return nil, err } if entry == nil { return nil, fmt.Errorf("no entry for %s", fileName) } if len(entry.Content) > 0 || len(entry.GetChunks()) == 0 { return entry.Content, nil } if size := filer.FileSize(entry); size > maxRepairableManifestSize { return nil, fmt.Errorf("%s is %d bytes, larger than the %d byte repair limit", fileName, size, maxRepairableManifestSize) } fullClient, ok := s.filerClient.(filer_pb.FilerClient) if !ok { return nil, fmt.Errorf("filer client cannot resolve chunk locations") } var buf bytes.Buffer lookup := &lookupFileIDAdapter{fn: filer.LookupFn(fullClient)} if err := filer.StreamContent(lookup, &buf, entry.GetChunks(), 0, int64(filer.FileSize(entry))); err != nil { return nil, fmt.Errorf("read %s: %w", fileName, err) } return buf.Bytes(), nil }