mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-18 21:26:56 +00:00
iceberg: repair non-compliant manifests at commit (#10641)
* 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
This commit is contained in:
@@ -7,6 +7,6 @@ WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir "pyiceberg[s3fs]==0.11.1" "pyarrow==25.0.0"
|
||||
|
||||
COPY append_rows.py /app/
|
||||
COPY append_rows.py read_rows.py /app/
|
||||
|
||||
ENTRYPOINT ["python3", "/app/append_rows.py"]
|
||||
|
||||
@@ -32,6 +32,12 @@ database engine.
|
||||
- `ReadWrittenDataCount` and `ReadWrittenDataValues`: ClickHouse reads back
|
||||
the three PyIceberg-appended rows and the values match. This exercises the
|
||||
actual data path (parquet reads via S3), not just metadata.
|
||||
- `WriteReadBack`: ClickHouse inserts rows with its experimental Iceberg
|
||||
write support using default settings, which produces manifests without
|
||||
avro field-ids, bucket-relative paths, and parquet without field ids. The
|
||||
SeaweedFS catalog repairs the manifests at commit time and stamps a
|
||||
default name mapping on the table, so PyIceberg (`read_rows.py`, a strict
|
||||
reader) must return the rows ClickHouse wrote.
|
||||
|
||||
Queries go through ClickHouse's HTTP interface (port 8123, mapped to a
|
||||
dynamically allocated host port), so the test needs no ClickHouse client
|
||||
|
||||
@@ -105,6 +105,10 @@ func TestClickHouseIcebergCatalog(t *testing.T) {
|
||||
buildClickHouseWriterImage(t)
|
||||
writeIcebergRows(t, env, tableBucket, []string{namespace}, populatedTable)
|
||||
|
||||
// Empty table that ClickHouse writes into during the WriteReadBack subtest.
|
||||
writeTable := "chwrite_" + randomString(6)
|
||||
createIcebergTable(t, env, icebergToken, tableBucket, namespace, writeTable)
|
||||
|
||||
env.startClickHouseContainer(t)
|
||||
env.waitForClickHouse(t, clickhouseStartTimeout)
|
||||
|
||||
@@ -169,6 +173,28 @@ func TestClickHouseIcebergCatalog(t *testing.T) {
|
||||
t.Fatalf("SELECT id, label FROM %s = %q, want %q", populatedRef, out, want)
|
||||
}
|
||||
})
|
||||
|
||||
// ClickHouse's experimental Iceberg writes produce manifests without avro
|
||||
// field-ids, bucket-relative paths, and parquet without field ids. The
|
||||
// catalog repairs the manifests at commit and stamps a name mapping on the
|
||||
// table, so a strict reader (PyIceberg) must see ClickHouse's rows.
|
||||
t.Run("WriteReadBack", func(t *testing.T) {
|
||||
writeRef := fmt.Sprintf("%s.`%s.%s`", clickhouseDatabase, namespace, writeTable)
|
||||
insert := fmt.Sprintf("INSERT INTO %s (id, label) VALUES (1, 'alpha'), (2, 'beta')", writeRef)
|
||||
if _, err := env.query(insert, map[string]string{"allow_experimental_insert_into_iceberg": "1"}); err != nil {
|
||||
t.Fatalf("%s: %v\nContainer logs:\n%s", insert, err, clickhouseContainerLogs(env.clickhouseContainer))
|
||||
}
|
||||
|
||||
out := env.mustQuery(t, fmt.Sprintf("SELECT id, label FROM %s ORDER BY id", writeRef))
|
||||
if want := "1\talpha\n2\tbeta"; out != want {
|
||||
t.Fatalf("ClickHouse read-back = %q, want %q", out, want)
|
||||
}
|
||||
|
||||
rows := readIcebergRows(t, env, tableBucket, []string{namespace}, writeTable)
|
||||
if want := "1,alpha\n2,beta"; rows != want {
|
||||
t.Fatalf("PyIceberg read of ClickHouse-written table = %q, want %q", rows, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// NewTestEnvironment allocates ports and returns an environment for the test.
|
||||
@@ -620,6 +646,42 @@ func writeIcebergRows(t *testing.T, env *TestEnvironment, bucketName string, nam
|
||||
t.Logf("PyIceberg writer output: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// readIcebergRows scans a table with PyIceberg through the REST catalog and
|
||||
// returns its "id,label" lines, ordered by id.
|
||||
func readIcebergRows(t *testing.T, env *TestEnvironment, bucketName string, namespace []string, tableName string) string {
|
||||
t.Helper()
|
||||
|
||||
args := []string{
|
||||
"run", "--rm",
|
||||
"--add-host", "host.docker.internal:host-gateway",
|
||||
"--entrypoint", "python3",
|
||||
clickhouseWriterImage,
|
||||
"/app/read_rows.py",
|
||||
"--catalog-url", fmt.Sprintf("http://host.docker.internal:%d", env.icebergPort),
|
||||
"--warehouse", "s3://" + bucketName,
|
||||
"--prefix", bucketName,
|
||||
"--s3-endpoint", fmt.Sprintf("http://host.docker.internal:%d", env.s3Port),
|
||||
"--access-key", env.accessKey,
|
||||
"--secret-key", env.secretKey,
|
||||
"--region", "us-west-2",
|
||||
"--table", tableName,
|
||||
}
|
||||
for _, level := range namespace {
|
||||
args = append(args, "--namespace", level)
|
||||
}
|
||||
|
||||
// Keep stdout separate: the caller compares it exactly, and warnings on
|
||||
// stderr from the python stack must not pollute the row data.
|
||||
cmd := exec.Command("docker", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("PyIceberg reader failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
return strings.TrimSpace(stdout.String())
|
||||
}
|
||||
|
||||
// doIcebergJSONRequest issues an authenticated JSON request to the Iceberg
|
||||
// REST endpoint and returns the response body. It fails the test unless the
|
||||
// response status matches one of expectedStatuses.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read all rows of an Iceberg table via the SeaweedFS REST catalog.
|
||||
|
||||
Used by the ClickHouse integration test to prove that rows written by
|
||||
ClickHouse are readable by another engine: PyIceberg is a strict reader that
|
||||
requires spec-compliant manifests and either parquet field ids or a name
|
||||
mapping. Prints one "id,label" line per row, ordered by id.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from pyiceberg.catalog import load_catalog
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--catalog-url", required=True)
|
||||
p.add_argument("--warehouse", required=True)
|
||||
p.add_argument("--prefix", required=True)
|
||||
p.add_argument("--s3-endpoint", required=True)
|
||||
p.add_argument("--access-key", required=True)
|
||||
p.add_argument("--secret-key", required=True)
|
||||
p.add_argument("--region", default="us-east-1")
|
||||
p.add_argument("--namespace", action="append", required=True)
|
||||
p.add_argument("--table", required=True)
|
||||
args = p.parse_args()
|
||||
|
||||
catalog = load_catalog(
|
||||
"rest",
|
||||
**{
|
||||
"type": "rest",
|
||||
"uri": args.catalog_url,
|
||||
"warehouse": args.warehouse,
|
||||
"prefix": args.prefix,
|
||||
"credential": f"{args.access_key}:{args.secret_key}",
|
||||
"s3.access-key-id": args.access_key,
|
||||
"s3.secret-access-key": args.secret_key,
|
||||
"s3.endpoint": args.s3_endpoint,
|
||||
"s3.region": args.region,
|
||||
"s3.path-style-access": "true",
|
||||
},
|
||||
)
|
||||
|
||||
table = catalog.load_table(tuple(args.namespace) + (args.table,))
|
||||
data = table.scan().to_arrow().to_pydict()
|
||||
rows = sorted(zip(data["id"], data["label"]))
|
||||
for row_id, label in rows:
|
||||
print(f"{row_id},{label}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -133,6 +133,7 @@ func (s *Server) finalizeCreateOnCommit(ctx context.Context, input createOnCommi
|
||||
message: "Failed to apply statistics updates: " + err.Error(),
|
||||
}
|
||||
}
|
||||
metadataBytes = refreshDefaultNameMapping(metadataBytes, newMetadata)
|
||||
// Same spec-compliance fixup we apply on create-table; ensures
|
||||
// v{N}.metadata.json files written through this create-on-commit path are
|
||||
// also readable by strict Iceberg clients reading directly from S3.
|
||||
|
||||
@@ -66,6 +66,30 @@ func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Manifest repair runs once, as soon as the table location is known; on
|
||||
// commit retries the updates already reference the repaired files. Repair
|
||||
// is best effort end to end: the originals parsed already, so a repair
|
||||
// that fails to re-parse is discarded rather than failing the commit.
|
||||
manifestsRepaired := false
|
||||
repairManifests := func(location string) {
|
||||
if manifestsRepaired {
|
||||
return
|
||||
}
|
||||
manifestsRepaired = true
|
||||
repaired, changed := s.repairAddSnapshotManifests(r.Context(), location, raw.Updates)
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
repairedUpdates, repairedStatistics, err := parseCommitUpdates(repaired)
|
||||
if err != nil {
|
||||
glog.Warningf("Iceberg: repaired updates failed to parse, keeping originals: %v", err)
|
||||
return
|
||||
}
|
||||
raw.Updates = repaired
|
||||
req.Updates = repairedUpdates
|
||||
statisticsUpdates = repairedStatistics
|
||||
}
|
||||
|
||||
maxCommitAttempts := 3
|
||||
generatedLegacyUUID := uuid.New()
|
||||
stageCreateEnabled := isStageCreateEnabled()
|
||||
@@ -168,6 +192,8 @@ func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
repairManifests(location)
|
||||
|
||||
result, reqErr := s.finalizeCreateOnCommit(r.Context(), createOnCommitInput{
|
||||
bucketARN: bucketARN,
|
||||
markerBucket: bucketName,
|
||||
@@ -232,6 +258,8 @@ func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
repairManifests(location)
|
||||
|
||||
builder, err := table.MetadataBuilderFromBase(currentMetadata, getResp.MetadataLocation)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to create metadata builder: "+err.Error())
|
||||
@@ -266,6 +294,7 @@ func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to apply statistics updates: "+err.Error())
|
||||
return
|
||||
}
|
||||
metadataBytes = refreshDefaultNameMapping(metadataBytes, newMetadata)
|
||||
// Same spec-compliance fixup we apply on create-table; ensures
|
||||
// v{N}.metadata.json files written during commit are also readable by
|
||||
// strict Iceberg clients reading directly from S3, and that the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -851,5 +852,20 @@ func newTableMetadata(
|
||||
}
|
||||
|
||||
// Create metadata directly using the constructor which ensures spec compliance for V2
|
||||
return table.NewMetadataWithUUID(s, pSpec, so, location, props, tableUUID)
|
||||
metadata, err := table.NewMetadataWithUUID(s, pSpec, so, location, props, tableUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The constructor reassigns field ids, so the default name mapping must be
|
||||
// derived from the final schema rather than the request's.
|
||||
raw, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if patched := refreshDefaultNameMapping(raw, metadata); !bytes.Equal(patched, raw) {
|
||||
if withMapping, err := table.ParseMetadataBytes(patched); err == nil {
|
||||
return withMapping, nil
|
||||
}
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
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")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
icebergmanifest "github.com/apache/iceberg-go"
|
||||
"github.com/linkedin/goavro/v2"
|
||||
)
|
||||
|
||||
// The testdata fixtures were written by ClickHouse 25.8's experimental
|
||||
// Iceberg insert against a SeaweedFS table bucket. clickhouse_rel holds the
|
||||
// default output (bucket-relative paths, no field-id annotations);
|
||||
// clickhouse_abs was written with write_full_path_in_iceberg_metadata=1
|
||||
// (absolute paths, still no field-ids).
|
||||
|
||||
type fakeManifestStore struct {
|
||||
files map[string][]byte
|
||||
saved map[string][]byte
|
||||
}
|
||||
|
||||
func newFakeManifestStore() *fakeManifestStore {
|
||||
return &fakeManifestStore{files: map[string][]byte{}, saved: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (f *fakeManifestStore) loadFile(_ context.Context, location string) ([]byte, error) {
|
||||
if data, ok := f.files[location]; ok {
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no file at %s", location)
|
||||
}
|
||||
|
||||
func (f *fakeManifestStore) saveFile(_ context.Context, location string, data []byte) error {
|
||||
f.files[location] = data
|
||||
f.saved[location] = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadFixture(t *testing.T, parts ...string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(append([]string{"testdata"}, parts...)...))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const (
|
||||
relTableLocation = "s3://iceberg-tables/wprobe/target_rel"
|
||||
relListName = "snap-449670589-2-48b874af-90f6-4e5e-8002-df91e59c8a0c.avro"
|
||||
relManifestName = "690958a2-9936-4a04-8117-d8104c977195.avro"
|
||||
absTableLocation = "s3://iceberg-tables/wprobe/target"
|
||||
absListName = "snap-331611856-2-51798e6c-427b-4fc2-9149-99e75d96e045.avro"
|
||||
absManifestName = "b317f0e5-10f3-41d6-86bc-8b42f01f3e86.avro"
|
||||
)
|
||||
|
||||
// verifyRepairedManifests checks the repaired files with iceberg-go's strict
|
||||
// typed readers: the manifest list parses, references the repaired manifest
|
||||
// with the right length, and the manifest's data file path is absolute.
|
||||
func verifyRepairedManifests(t *testing.T, store *fakeManifestStore, listLocation, tableLocation string) {
|
||||
t.Helper()
|
||||
|
||||
listBytes, ok := store.files[listLocation]
|
||||
if !ok {
|
||||
t.Fatalf("repaired manifest list %s was not saved", listLocation)
|
||||
}
|
||||
if !bytes.Contains(listBytes, []byte(`"field-id"`)) {
|
||||
t.Fatalf("repaired manifest list schema has no field-id annotations")
|
||||
}
|
||||
|
||||
manifests, err := icebergmanifest.ReadManifestList(bytes.NewReader(listBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("iceberg-go cannot read repaired manifest list: %v", err)
|
||||
}
|
||||
if len(manifests) != 1 {
|
||||
t.Fatalf("manifest list has %d entries, want 1", len(manifests))
|
||||
}
|
||||
manifestPath := manifests[0].FilePath()
|
||||
if !strings.HasPrefix(manifestPath, tableLocation+"/metadata/") {
|
||||
t.Fatalf("manifest path %s is not absolute under the table location", manifestPath)
|
||||
}
|
||||
|
||||
manifestBytes, ok := store.files[manifestPath]
|
||||
if !ok {
|
||||
t.Fatalf("manifest %s not present in store", manifestPath)
|
||||
}
|
||||
if manifests[0].Length() != int64(len(manifestBytes)) {
|
||||
t.Fatalf("manifest_length %d != stored size %d", manifests[0].Length(), len(manifestBytes))
|
||||
}
|
||||
if !bytes.Contains(manifestBytes, []byte(`"field-id"`)) {
|
||||
t.Fatalf("repaired manifest schema has no field-id annotations")
|
||||
}
|
||||
|
||||
entries, err := icebergmanifest.ReadManifest(manifests[0], bytes.NewReader(manifestBytes), false)
|
||||
if err != nil {
|
||||
t.Fatalf("iceberg-go cannot read repaired manifest: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("manifest has %d entries, want 1", len(entries))
|
||||
}
|
||||
dataFile := entries[0].DataFile()
|
||||
if !strings.HasPrefix(dataFile.FilePath(), "s3://iceberg-tables/") {
|
||||
t.Fatalf("data file path %s is not absolute", dataFile.FilePath())
|
||||
}
|
||||
if dataFile.Count() != 2 {
|
||||
t.Fatalf("record count %d, want 2", dataFile.Count())
|
||||
}
|
||||
if len(dataFile.LowerBoundValues()) == 0 {
|
||||
t.Fatalf("lower bounds were lost in repair")
|
||||
}
|
||||
}
|
||||
|
||||
func addSnapshotUpdateJSON(t *testing.T, manifestList string) []json.RawMessage {
|
||||
t.Helper()
|
||||
update := fmt.Sprintf(`{"action":"add-snapshot","snapshot":{"snapshot-id":449670589,"sequence-number":1,"timestamp-ms":1754625869000,"manifest-list":%q,"summary":{"operation":"append"},"schema-id":0}}`, manifestList)
|
||||
return []json.RawMessage{json.RawMessage(update)}
|
||||
}
|
||||
|
||||
func TestRepairClickHouseRelativeManifests(t *testing.T) {
|
||||
store := newFakeManifestStore()
|
||||
store.files[relTableLocation+"/metadata/"+relListName] = loadFixture(t, "clickhouse_rel", relListName)
|
||||
store.files[relTableLocation+"/metadata/"+relManifestName] = loadFixture(t, "clickhouse_rel", relManifestName)
|
||||
|
||||
rawUpdates := addSnapshotUpdateJSON(t, "wprobe/target_rel/metadata/"+relListName)
|
||||
repaired, changed := repairAddSnapshotUpdates(context.Background(), store, relTableLocation, rawUpdates)
|
||||
if !changed {
|
||||
t.Fatal("expected repair to change the update")
|
||||
}
|
||||
|
||||
var update struct {
|
||||
Snapshot struct {
|
||||
ManifestList string `json:"manifest-list"`
|
||||
SnapshotID int64 `json:"snapshot-id"`
|
||||
Summary struct {
|
||||
Operation string `json:"operation"`
|
||||
} `json:"summary"`
|
||||
} `json:"snapshot"`
|
||||
}
|
||||
if err := json.Unmarshal(repaired[0], &update); err != nil {
|
||||
t.Fatalf("unmarshal repaired update: %v", err)
|
||||
}
|
||||
wantList := relTableLocation + "/metadata/" + repairedManifestPrefix + relListName
|
||||
if update.Snapshot.ManifestList != wantList {
|
||||
t.Fatalf("manifest-list = %s, want %s", update.Snapshot.ManifestList, wantList)
|
||||
}
|
||||
// The rest of the snapshot survives the rewrite.
|
||||
if update.Snapshot.SnapshotID != 449670589 || update.Snapshot.Summary.Operation != "append" {
|
||||
t.Fatalf("snapshot fields were disturbed: %s", repaired[0])
|
||||
}
|
||||
|
||||
verifyRepairedManifests(t, store, wantList, relTableLocation)
|
||||
}
|
||||
|
||||
func TestRepairClickHouseAbsoluteManifests(t *testing.T) {
|
||||
store := newFakeManifestStore()
|
||||
listLocation := absTableLocation + "/metadata/" + absListName
|
||||
store.files[listLocation] = loadFixture(t, "clickhouse_abs", absListName)
|
||||
store.files[absTableLocation+"/metadata/"+absManifestName] = loadFixture(t, "clickhouse_abs", absManifestName)
|
||||
|
||||
repaired, changed, err := repairManifestList(context.Background(), store, absTableLocation, listLocation)
|
||||
if err != nil {
|
||||
t.Fatalf("repairManifestList: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("expected repair for missing field-ids")
|
||||
}
|
||||
verifyRepairedManifests(t, store, repaired, absTableLocation)
|
||||
}
|
||||
|
||||
func TestRepairedManifestsAreLeftAlone(t *testing.T) {
|
||||
store := newFakeManifestStore()
|
||||
listLocation := absTableLocation + "/metadata/" + absListName
|
||||
store.files[listLocation] = loadFixture(t, "clickhouse_abs", absListName)
|
||||
store.files[absTableLocation+"/metadata/"+absManifestName] = loadFixture(t, "clickhouse_abs", absManifestName)
|
||||
|
||||
firstPass, changed, err := repairManifestList(context.Background(), store, absTableLocation, listLocation)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("first pass: changed=%v err=%v", changed, err)
|
||||
}
|
||||
|
||||
store.saved = map[string][]byte{}
|
||||
secondPass, changed, err := repairManifestList(context.Background(), store, absTableLocation, firstPass)
|
||||
if err != nil {
|
||||
t.Fatalf("second pass: %v", err)
|
||||
}
|
||||
if changed || secondPass != firstPass {
|
||||
t.Fatalf("second pass rewrote an already-repaired list: changed=%v location=%s", changed, secondPass)
|
||||
}
|
||||
if len(store.saved) != 0 {
|
||||
t.Fatalf("second pass saved files: %v", store.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairKeepsDeleteManifestContent(t *testing.T) {
|
||||
// The fixture lacks the OCF "content" key; the repaired copy must take the
|
||||
// content declared by the manifest-list entry, not default to data.
|
||||
raw := loadFixture(t, "clickhouse_rel", relManifestName)
|
||||
repaired, changed, err := repairManifest(raw, relTableLocation, 1)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("repairManifest: changed=%v err=%v", changed, err)
|
||||
}
|
||||
reader, err := goavro.NewOCFReader(bytes.NewReader(repaired))
|
||||
if err != nil {
|
||||
t.Fatalf("read repaired manifest: %v", err)
|
||||
}
|
||||
if got := string(reader.MetaData()["content"]); got != "deletes" {
|
||||
t.Fatalf(`OCF content = %q, want "deletes"`, got)
|
||||
}
|
||||
|
||||
asData, _, err := repairManifest(raw, relTableLocation, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("repairManifest: %v", err)
|
||||
}
|
||||
reader, err = goavro.NewOCFReader(bytes.NewReader(asData))
|
||||
if err != nil {
|
||||
t.Fatalf("read repaired manifest: %v", err)
|
||||
}
|
||||
if got := string(reader.MetaData()["content"]); got != "data" {
|
||||
t.Fatalf(`OCF content = %q, want "data"`, got)
|
||||
}
|
||||
|
||||
// An existing content value that contradicts the manifest-list entry is
|
||||
// corrected, not preserved.
|
||||
relabeled, changed, err := repairManifest(asData, relTableLocation, 1)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("repairManifest on mismatched content: changed=%v err=%v", changed, err)
|
||||
}
|
||||
reader, err = goavro.NewOCFReader(bytes.NewReader(relabeled))
|
||||
if err != nil {
|
||||
t.Fatalf("read relabeled manifest: %v", err)
|
||||
}
|
||||
if got := string(reader.MetaData()["content"]); got != "deletes" {
|
||||
t.Fatalf(`OCF content after relabel = %q, want "deletes"`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestContentValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
record map[string]any
|
||||
want int
|
||||
}{
|
||||
{map[string]any{"content": int32(1)}, 1},
|
||||
{map[string]any{"content": int64(1)}, 1},
|
||||
{map[string]any{"content": map[string]any{"int": int32(1)}}, 1},
|
||||
{map[string]any{"content": nil}, 0},
|
||||
{map[string]any{}, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := manifestContentValue(c.record); got != c.want {
|
||||
t.Errorf("manifestContentValue(%v) = %d, want %d", c.record, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairSkipsLocationsOutsideMetadataDir(t *testing.T) {
|
||||
store := newFakeManifestStore()
|
||||
rawUpdates := addSnapshotUpdateJSON(t, "s3://other-bucket/elsewhere/list.avro")
|
||||
_, changed := repairAddSnapshotUpdates(context.Background(), store, relTableLocation, rawUpdates)
|
||||
if changed {
|
||||
t.Fatal("repair touched a manifest list outside the table metadata directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairLeavesUnreadableListAlone(t *testing.T) {
|
||||
store := newFakeManifestStore()
|
||||
store.files[relTableLocation+"/metadata/junk.avro"] = []byte("not avro")
|
||||
rawUpdates := addSnapshotUpdateJSON(t, relTableLocation+"/metadata/junk.avro")
|
||||
repaired, changed := repairAddSnapshotUpdates(context.Background(), store, relTableLocation, rawUpdates)
|
||||
if changed {
|
||||
t.Fatal("repair claimed to change an unreadable manifest list")
|
||||
}
|
||||
if !bytes.Equal(repaired[0], rawUpdates[0]) {
|
||||
t.Fatal("update was modified despite repair failure")
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,23 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// metadataDirPath returns the filer directory holding a table's metadata files.
|
||||
func metadataDirPath(bucketName, tablePath string) string {
|
||||
dir := path.Join(s3tables.TablesPath, bucketName)
|
||||
if tablePath != "" {
|
||||
dir = path.Join(dir, tablePath)
|
||||
}
|
||||
return path.Join(dir, "metadata")
|
||||
}
|
||||
|
||||
// saveMetadataFile saves the Iceberg metadata JSON file to the filer.
|
||||
// It constructs the filer path from the S3 location components.
|
||||
func (s *Server) saveMetadataFile(ctx context.Context, bucketName, tablePath, metadataFileName string, content []byte) error {
|
||||
return s.saveMetadataBlob(ctx, bucketName, tablePath, metadataFileName, content, "application/json")
|
||||
}
|
||||
|
||||
// saveMetadataBlob saves a file into the table's metadata directory.
|
||||
func (s *Server) saveMetadataBlob(ctx context.Context, bucketName, tablePath, metadataFileName string, content []byte, mimeType string) error {
|
||||
|
||||
// Create context with timeout for file operations
|
||||
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
@@ -97,7 +111,7 @@ func (s *Server) saveMetadataFile(ctx context.Context, bucketName, tablePath, me
|
||||
},
|
||||
Content: content,
|
||||
Extended: map[string][]byte{
|
||||
"Mime-Type": []byte("application/json"),
|
||||
"Mime-Type": []byte(mimeType),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -118,11 +132,7 @@ func (s *Server) deleteMetadataFile(ctx context.Context, bucketName, tablePath,
|
||||
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
metadataDir := path.Join(s3tables.TablesPath, bucketName)
|
||||
if tablePath != "" {
|
||||
metadataDir = path.Join(metadataDir, tablePath)
|
||||
}
|
||||
metadataDir = path.Join(metadataDir, "metadata")
|
||||
metadataDir := metadataDirPath(bucketName, tablePath)
|
||||
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
return filer_pb.DoRemove(opCtx, client, metadataDir, metadataFileName, true, false, true, false, nil)
|
||||
})
|
||||
@@ -132,11 +142,7 @@ func (s *Server) loadMetadataFile(ctx context.Context, bucketName, tablePath, me
|
||||
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
metadataDir := path.Join(s3tables.TablesPath, bucketName)
|
||||
if tablePath != "" {
|
||||
metadataDir = path.Join(metadataDir, tablePath)
|
||||
}
|
||||
metadataDir = path.Join(metadataDir, "metadata")
|
||||
metadataDir := metadataDirPath(bucketName, tablePath)
|
||||
|
||||
var content []byte
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
)
|
||||
|
||||
// nameMappingFromSchema derives the spec's field-id-to-names mapping from a
|
||||
// table schema, mirroring Java's MappingUtil.create. List elements and map
|
||||
// keys/values use the canonical "element"/"key"/"value" names.
|
||||
func nameMappingFromSchema(schema *iceberg.Schema) iceberg.NameMapping {
|
||||
return mappedFieldsOf(schema.Fields())
|
||||
}
|
||||
|
||||
func mappedFieldsOf(fields []iceberg.NestedField) []iceberg.MappedField {
|
||||
mapped := make([]iceberg.MappedField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
id := field.ID
|
||||
mapped = append(mapped, iceberg.MappedField{
|
||||
Names: []string{field.Name},
|
||||
FieldID: &id,
|
||||
Fields: mappedFieldsOfType(field.Type),
|
||||
})
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
func mappedFieldsOfType(typ iceberg.Type) []iceberg.MappedField {
|
||||
switch t := typ.(type) {
|
||||
case *iceberg.StructType:
|
||||
return mappedFieldsOf(t.FieldList)
|
||||
case *iceberg.ListType:
|
||||
return mappedFieldsOf([]iceberg.NestedField{t.ElementField()})
|
||||
case *iceberg.MapType:
|
||||
return mappedFieldsOf([]iceberg.NestedField{t.KeyField(), t.ValueField()})
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// refreshDefaultNameMapping keeps schema.name-mapping.default in sync with
|
||||
// the current schema across commits, so schema evolution does not strand
|
||||
// readers of field-id-less data files on the creation-time mapping. The
|
||||
// stored mapping is merged rather than replaced: per field-id, names already
|
||||
// present are kept alongside the current schema name, so files written under
|
||||
// a renamed column's old name (and user-added aliases) stay resolvable.
|
||||
// Returns the (possibly patched) serialized metadata.
|
||||
func refreshDefaultNameMapping(raw []byte, updated table.Metadata) []byte {
|
||||
if updated == nil {
|
||||
return raw
|
||||
}
|
||||
updatedSchema := updated.CurrentSchema()
|
||||
if updatedSchema == nil || len(updatedSchema.Fields()) == 0 {
|
||||
return raw
|
||||
}
|
||||
mapping := nameMappingFromSchema(updatedSchema)
|
||||
|
||||
existing := updated.Properties()[table.DefaultNameMappingKey]
|
||||
if existing != "" {
|
||||
var existingMapping iceberg.NameMapping
|
||||
if err := json.Unmarshal([]byte(existing), &existingMapping); err != nil {
|
||||
// Not a mapping we can merge; leave whatever the user stored.
|
||||
return raw
|
||||
}
|
||||
mapping = mergeMappedFields(existingMapping, mapping)
|
||||
}
|
||||
wantJSON, err := json.Marshal(mapping)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
want := string(wantJSON)
|
||||
if existing == want {
|
||||
return raw
|
||||
}
|
||||
|
||||
var metadata map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil {
|
||||
return raw
|
||||
}
|
||||
properties := map[string]json.RawMessage{}
|
||||
if rawProperties, ok := metadata["properties"]; ok {
|
||||
if err := json.Unmarshal(rawProperties, &properties); err != nil {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
valueJSON, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
properties[table.DefaultNameMappingKey] = valueJSON
|
||||
propertiesJSON, err := json.Marshal(properties)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
metadata["properties"] = propertiesJSON
|
||||
patched, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return patched
|
||||
}
|
||||
|
||||
// mergeMappedFields folds the names of an existing mapping into the mapping
|
||||
// derived from the current schema. Fields are matched by field-id per nesting
|
||||
// level; entries whose ids left the schema are dropped, since current readers
|
||||
// cannot project those fields anyway. A name can belong to only one field per
|
||||
// level (Java readers reject ambiguous mappings), so a historical name that
|
||||
// the current schema assigns to a different field-id stays with its new owner
|
||||
// instead of being duplicated onto the old one — which also keeps mappings
|
||||
// clean when a commit replaces the schema with reassigned field ids.
|
||||
func mergeMappedFields(existing, derived []iceberg.MappedField) []iceberg.MappedField {
|
||||
byID := make(map[int]*iceberg.MappedField, len(existing))
|
||||
for i := range existing {
|
||||
byID[existing[i].ID()] = &existing[i]
|
||||
}
|
||||
nameOwner := make(map[string]int)
|
||||
for _, d := range derived {
|
||||
for _, name := range d.Names {
|
||||
nameOwner[name] = d.ID()
|
||||
}
|
||||
}
|
||||
for i := range derived {
|
||||
prior, ok := byID[derived[i].ID()]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, name := range prior.Names {
|
||||
if ownerID, taken := nameOwner[name]; taken && ownerID != derived[i].ID() {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(derived[i].Names, name) {
|
||||
derived[i].Names = append(derived[i].Names, name)
|
||||
nameOwner[name] = derived[i].ID()
|
||||
}
|
||||
}
|
||||
derived[i].Fields = mergeMappedFields(prior.Fields, derived[i].Fields)
|
||||
}
|
||||
return derived
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user