This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 5623f812f perf(table): make bulk schema field moves linear (#2277)
5623f812f is described below

commit 5623f812f654253d7f544421d01681e58c1d9de7
Author: Minh Vu <[email protected]>
AuthorDate: Fri Oct 23 18:56:28 2026 +0200

    perf(table): make bulk schema field moves linear (#2277)
    
    * perf(table): index bulk schema field moves
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * style(table): satisfy lint in bulk schema moves
    
    * style(table): satisfy lint in bulk schema moves
    
    * style(table): satisfy lint in bulk schema moves
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/update_schema.go                    |  97 ++++++++++++++++++++
 table/update_schema_moves_bench_test.go   |  81 +++++++++++++++++
 table/update_schema_moves_indexed_test.go | 143 ++++++++++++++++++++++++++++++
 3 files changed, 321 insertions(+)

diff --git a/table/update_schema.go b/table/update_schema.go
index dea77812f..e4e5c95dd 100644
--- a/table/update_schema.go
+++ b/table/update_schema.go
@@ -1347,7 +1347,104 @@ func addFields(fields []iceberg.NestedField, adds 
[]iceberg.NestedField) []icebe
        return append(fields, adds...)
 }
 
+// fieldMoveLink stores stable field positions while moves relink their order.
+// Unlike slice insertions, each move changes only the adjacent links.
+type fieldMoveLink struct {
+       prev int
+       next int
+}
+
+// moveFields avoids the repeated linear searches and slice copies involved
+// in bulk reordering wide schemas. Small move sets keep the simpler linear
+// path to avoid allocating an index for the common one- or two-move case.
 func moveFields(fields []iceberg.NestedField, moves []move) 
[]iceberg.NestedField {
+       if len(fields) < 64 || len(moves) < 8 {
+               return moveFieldsLinear(fields, moves)
+       }
+
+       for _, m := range moves {
+               // Preserve the existing handling of unexpected move operations.
+               if m.Op != MoveOpFirst && m.Op != MoveOpBefore && m.Op != 
MoveOpAfter {
+                       return moveFieldsLinear(fields, moves)
+               }
+       }
+
+       links := make([]fieldMoveLink, len(fields))
+       positions := make(map[int]int, len(fields))
+       for i, field := range fields {
+               // Duplicate IDs are invalid for a normal schema, but preserve 
the
+               // original first-match behavior if they reach this helper.
+               if _, exists := positions[field.ID]; exists {
+                       return moveFieldsLinear(fields, moves)
+               }
+               positions[field.ID] = i
+               links[i] = fieldMoveLink{prev: i - 1, next: i + 1}
+       }
+       links[len(links)-1].next = -1
+
+       head := 0
+       for _, m := range moves {
+               idx, ok := positions[m.FieldID]
+               if !ok {
+                       panic(fmt.Errorf("cannot move field %d: field not 
found", m.FieldID))
+               }
+
+               relativeIdx := -1
+               if m.Op == MoveOpBefore || m.Op == MoveOpAfter {
+                       var found bool
+                       relativeIdx, found = positions[m.RelativeTo]
+                       // The original implementation removes the source 
before finding
+                       // the target, so a self-relative move must still fail.
+                       if !found || relativeIdx == idx {
+                               panic(fmt.Errorf("cannot move field %d: target 
field %d not found", m.FieldID, m.RelativeTo))
+                       }
+               }
+
+               prev, next := links[idx].prev, links[idx].next
+               if prev >= 0 {
+                       links[prev].next = next
+               } else {
+                       head = next
+               }
+               if next >= 0 {
+                       links[next].prev = prev
+               }
+
+               switch m.Op {
+               case MoveOpFirst:
+                       links[idx] = fieldMoveLink{prev: -1, next: head}
+                       if head >= 0 {
+                               links[head].prev = idx
+                       }
+                       head = idx
+               case MoveOpBefore:
+                       prev = links[relativeIdx].prev
+                       links[idx] = fieldMoveLink{prev: prev, next: 
relativeIdx}
+                       links[relativeIdx].prev = idx
+                       if prev >= 0 {
+                               links[prev].next = idx
+                       } else {
+                               head = idx
+                       }
+               case MoveOpAfter:
+                       next = links[relativeIdx].next
+                       links[idx] = fieldMoveLink{prev: relativeIdx, next: 
next}
+                       links[relativeIdx].next = idx
+                       if next >= 0 {
+                               links[next].prev = idx
+                       }
+               }
+       }
+
+       reordered := make([]iceberg.NestedField, 0, len(fields))
+       for idx := head; idx >= 0; idx = links[idx].next {
+               reordered = append(reordered, fields[idx])
+       }
+
+       return reordered
+}
+
+func moveFieldsLinear(fields []iceberg.NestedField, moves []move) 
[]iceberg.NestedField {
        reordered := slices.Clone(fields)
        for _, move := range moves {
                var fieldToMove iceberg.NestedField
diff --git a/table/update_schema_moves_bench_test.go 
b/table/update_schema_moves_bench_test.go
new file mode 100644
index 000000000..1ba2cfe54
--- /dev/null
+++ b/table/update_schema_moves_bench_test.go
@@ -0,0 +1,81 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "fmt"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+)
+
+var moveFieldsBenchmarkSink []iceberg.NestedField
+
+func BenchmarkMoveFieldsBulkReorder(b *testing.B) {
+       cases := []struct {
+               fieldCount int
+               moveCount  int
+               mixed      bool
+       }{
+               {16, 2, false},
+               {64, 8, false},
+               {256, 64, false},
+               {1024, 1024, false},
+               {4096, 4096, false},
+               {256, 64, true},
+               {1024, 1024, true},
+       }
+       for _, tc := range cases {
+               fields := makeMoveTestFields(tc.fieldCount)
+               moves := make([]move, tc.moveCount)
+               for i := range moves {
+                       moves[i] = move{FieldID: tc.fieldCount - 
i%tc.fieldCount, Op: MoveOpFirst}
+                       if tc.mixed {
+                               moves[i].FieldID = 1 + i%tc.fieldCount
+                               moves[i].RelativeTo = 1 + (i*7+3)%tc.fieldCount
+                               if moves[i].FieldID == moves[i].RelativeTo {
+                                       moves[i].RelativeTo = 1 + 
moves[i].RelativeTo%tc.fieldCount
+                               }
+                               switch i % 3 {
+                               case 1:
+                                       moves[i].Op = MoveOpBefore
+                               case 2:
+                                       moves[i].Op = MoveOpAfter
+                               }
+                       }
+               }
+               shape := "first"
+               if tc.mixed {
+                       shape = "mixed"
+               }
+               name := fmt.Sprintf("%s/%d_fields_%d_moves", shape, 
tc.fieldCount, tc.moveCount)
+               b.Run(name, func(b *testing.B) {
+                       for _, impl := range []struct {
+                               name string
+                               fn   func([]iceberg.NestedField, []move) 
[]iceberg.NestedField
+                       }{{"linear", moveFieldsLinear}, {"indexed", 
moveFields}} {
+                               b.Run(impl.name, func(b *testing.B) {
+                                       b.ReportAllocs()
+                                       for range b.N {
+                                               moveFieldsBenchmarkSink = 
impl.fn(fields, moves)
+                                       }
+                               })
+                       }
+               })
+       }
+}
diff --git a/table/update_schema_moves_indexed_test.go 
b/table/update_schema_moves_indexed_test.go
new file mode 100644
index 000000000..6f03da76e
--- /dev/null
+++ b/table/update_schema_moves_indexed_test.go
@@ -0,0 +1,143 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "fmt"
+       "math/rand"
+       "reflect"
+       "slices"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+)
+
+func makeMoveTestFields(count int) []iceberg.NestedField {
+       fields := make([]iceberg.NestedField, count)
+       for i := range fields {
+               fields[i] = iceberg.NestedField{ID: i + 1, Name: 
fmt.Sprintf("col_%d", i)}
+       }
+
+       return fields
+}
+
+func TestMoveFieldsIndexedMatchesLinear(t *testing.T) {
+       for _, count := range []int{1, 16, 63, 64, 128, 256, 1024} {
+               for _, moveCount := range []int{0, 1, 7, 8, 64, 256} {
+                       t.Run(fmt.Sprintf("%d_fields_%d_moves", count, 
moveCount), func(t *testing.T) {
+                               fields := makeMoveTestFields(count)
+                               original := slices.Clone(fields)
+                               for seed := range int64(10) {
+                                       rng := rand.New(rand.NewSource(seed))
+                                       moves := make([]move, moveCount)
+                                       for i := range moves {
+                                               moves[i].FieldID = 1 + 
rng.Intn(count)
+                                               moves[i].RelativeTo = 1 + 
rng.Intn(count)
+                                               if moves[i].RelativeTo == 
moves[i].FieldID {
+                                                       moves[i].RelativeTo = 1 
+ moves[i].RelativeTo%count
+                                               }
+                                               switch rng.Intn(3) {
+                                               case 0:
+                                                       moves[i].Op = 
MoveOpFirst
+                                               case 1:
+                                                       moves[i].Op = 
MoveOpBefore
+                                               case 2:
+                                                       moves[i].Op = 
MoveOpAfter
+                                               }
+                                               if count == 1 {
+                                                       moves[i].Op = 
MoveOpFirst
+                                               }
+                                       }
+                                       want := moveFieldsLinear(fields, moves)
+                                       got := moveFields(fields, moves)
+                                       if !reflect.DeepEqual(got, want) {
+                                               t.Fatalf("seed %d: move order 
mismatch: got %v, want %v", seed, got, want)
+                                       }
+                                       if !reflect.DeepEqual(fields, original) 
{
+                                               t.Fatal("moveFields mutated 
input fields")
+                                       }
+                               }
+                       })
+               }
+       }
+}
+
+func TestMoveFieldsIndexedFallbacks(t *testing.T) {
+       fields := makeMoveTestFields(128)
+       t.Run("missing source", func(t *testing.T) {
+               moves := make([]move, 8)
+               for i := range moves {
+                       moves[i] = move{FieldID: 99999, Op: MoveOpFirst}
+               }
+               assertSameMovePanic(t, fields, moves)
+       })
+       t.Run("missing target", func(t *testing.T) {
+               moves := make([]move, 8)
+               for i := range moves {
+                       moves[i] = move{FieldID: i + 1, RelativeTo: 99999, Op: 
MoveOpAfter}
+               }
+               assertSameMovePanic(t, fields, moves)
+       })
+       t.Run("self relative", func(t *testing.T) {
+               moves := make([]move, 8)
+               for i := range moves {
+                       moves[i] = move{FieldID: i + 1, RelativeTo: i + 1, Op: 
MoveOpBefore}
+               }
+               assertSameMovePanic(t, fields, moves)
+       })
+       t.Run("duplicate IDs", func(t *testing.T) {
+               duplicate := slices.Clone(fields)
+               duplicate[1].ID = duplicate[0].ID
+               moves := []move{
+                       {FieldID: 1, Op: MoveOpFirst},
+                       {FieldID: 3, Op: MoveOpFirst},
+                       {FieldID: 4, Op: MoveOpFirst},
+                       {FieldID: 5, Op: MoveOpFirst},
+                       {FieldID: 6, Op: MoveOpFirst},
+                       {FieldID: 7, Op: MoveOpFirst},
+                       {FieldID: 8, Op: MoveOpFirst},
+                       {FieldID: 9, Op: MoveOpFirst},
+               }
+               if got, want := moveFields(duplicate, moves), 
moveFieldsLinear(duplicate, moves); !reflect.DeepEqual(got, want) {
+                       t.Fatalf("duplicate field IDs: got %v, want %v", got, 
want)
+               }
+       })
+       t.Run("unknown operation", func(t *testing.T) {
+               moves := make([]move, 8)
+               for i := range moves {
+                       moves[i] = move{FieldID: i + 1, Op: MoveOp("unknown")}
+               }
+               if got, want := moveFields(fields, moves), 
moveFieldsLinear(fields, moves); !reflect.DeepEqual(got, want) {
+                       t.Fatalf("unknown operation: got %v, want %v", got, 
want)
+               }
+       })
+}
+
+func assertSameMovePanic(t *testing.T, fields []iceberg.NestedField, moves 
[]move) {
+       t.Helper()
+       getPanic := func(fn func([]iceberg.NestedField, []move) 
[]iceberg.NestedField) (panicValue any) {
+               defer func() { panicValue = recover() }()
+               fn(fields, moves)
+
+               return nil
+       }
+       got, want := getPanic(moveFields), getPanic(moveFieldsLinear)
+       if got == nil || fmt.Sprint(got) != fmt.Sprint(want) {
+               t.Fatalf("panic mismatch: got %v, want %v", got, want)
+       }
+}

Reply via email to