zeroshade commented on code in PR #1910:
URL: https://github.com/apache/iceberg-go/pull/1910#discussion_r3926374352


##########
table/metadata.go:
##########
@@ -216,6 +216,98 @@ func schemaIndexLookup(index *schemaIndexData, schemas 
[]*iceberg.Schema, id int
        return nil, false
 }
 
+type partitionSpecIndexData struct {
+       positions map[int]int
+       // firstSpec identifies the first element of the spec slice used to 
build
+       // positions. It lets read-only lookups detect an index left behind by 
an
+       // in-package fixture that replaced the slice.
+       firstSpec *iceberg.PartitionSpec
+       // shared means positions is owned by more than one builder or metadata
+       // value and must be copied before a builder mutates it.
+       shared bool
+}
+
+// Small spec slices are faster to search directly than to hash-map lookup.
+// 32 is a conservative cutoff; keep it aligned with the lookup benchmarks.
+const partitionSpecIndexMinSize = 32
+
+func partitionSpecListFirst(specs []iceberg.PartitionSpec) 
*iceberg.PartitionSpec {
+       if len(specs) == 0 {
+               return nil
+       }
+
+       return &specs[0]
+}
+
+func buildPartitionSpecIndex(specs []iceberg.PartitionSpec) 
*partitionSpecIndexData {
+       if len(specs) < partitionSpecIndexMinSize {
+               return nil
+       }
+
+       positions := make(map[int]int, len(specs))
+       for i := range specs {
+               id := specs[i].ID()
+               if _, exists := positions[id]; !exists {
+                       positions[id] = i
+               }
+       }
+
+       return &partitionSpecIndexData{
+               positions: positions,
+               firstSpec: partitionSpecListFirst(specs),
+       }
+}
+
+func clonePartitionSpecIndex(index *partitionSpecIndexData) 
*partitionSpecIndexData {
+       if index == nil {
+               return nil
+       }
+
+       return &partitionSpecIndexData{
+               positions: maps.Clone(index.positions),
+               firstSpec: index.firstSpec,
+       }
+}
+
+func partitionSpecIndexNeedsRebuild(index *partitionSpecIndexData, specs 
[]iceberg.PartitionSpec) bool {
+       if len(specs) < partitionSpecIndexMinSize {
+               return false
+       }
+       if index == nil {
+               return true
+       }
+       // Persisted metadata rejects duplicate IDs, so map and source 
cardinality
+       // are equal on the indexed read path.
+       if index.positions == nil || len(index.positions) != len(specs) {
+               return true
+       }
+
+       return len(specs) > 0 && index.firstSpec != &specs[0]
+}

Review Comment:
   **nit** — Unreachable sub-expressions in the new helpers
   
   At :285 'len(specs) > 0 &&' can never be false - 
partitionSpecIndexNeedsRebuild already returned for len(specs) < 
partitionSpecIndexMinSize. At :296 'i >= 0' is dead for map-sourced positions. 
Both shapes are inherited from snapshotIndexNeedsRebuild (:109) and 
snapshotIndexPosition (:118), where the length guard IS live because the 
snapshot index has no size gate; keeping them here is defensible for symmetry 
but a one-line note would stop a reader inferring the gate doesn't exist.



##########
table/metadata.go:
##########
@@ -742,14 +857,23 @@ func (b *MetadataBuilder) AddPartitionSpec(spec 
*iceberg.PartitionSpec, initial
        }
        lastPartitionID := max(maxFieldID, prev)
 
-       var specs []iceberg.PartitionSpec
        if initial {
-               specs = []iceberg.PartitionSpec{freshSpec}
+               b.specs = []iceberg.PartitionSpec{freshSpec}
+               b.partitionSpecIndex = buildPartitionSpecIndex(b.specs)
        } else {
-               specs = append(b.specs, freshSpec)
+               b.ensurePartitionSpecIndexMutable()
+               b.specs = append(b.specs, freshSpec)
+               if len(b.specs) >= partitionSpecIndexMinSize {
+                       if len(b.specs) == partitionSpecIndexMinSize ||
+                               b.partitionSpecIndex == nil || 
b.partitionSpecIndex.positions == nil {
+                               b.partitionSpecIndex = 
buildPartitionSpecIndex(b.specs)

Review Comment:
   **minor** — Dead nil sub-guards in AddPartitionSpec's incremental index 
update
   
   'b.partitionSpecIndex == nil || b.partitionSpecIndex.positions == nil' 
cannot hold when len(b.specs) > partitionSpecIndexMinSize: 
ensurePartitionSpecIndexMutable() two lines above calls 
ensurePartitionSpecIndex(), and for any pre-append length >= 32 
partitionSpecIndexNeedsRebuild returns true for both a nil index and a nil 
positions map, so buildPartitionSpecIndex has already installed a non-nil index 
with a non-nil map. Reduce the condition to 'len(b.specs) == 
partitionSpecIndexMinSize'. This is the same class of never-firing guard 
already removed twice in this PR at `laskoviymishka`'s request.



##########
table/metadata.go:
##########
@@ -216,6 +216,98 @@ func schemaIndexLookup(index *schemaIndexData, schemas 
[]*iceberg.Schema, id int
        return nil, false
 }
 
+type partitionSpecIndexData struct {
+       positions map[int]int
+       // firstSpec identifies the first element of the spec slice used to 
build
+       // positions. It lets read-only lookups detect an index left behind by 
an
+       // in-package fixture that replaced the slice.
+       firstSpec *iceberg.PartitionSpec
+       // shared means positions is owned by more than one builder or metadata
+       // value and must be copied before a builder mutates it.
+       shared bool
+}
+
+// Small spec slices are faster to search directly than to hash-map lookup.
+// 32 is a conservative cutoff; keep it aligned with the lookup benchmarks.
+const partitionSpecIndexMinSize = 32
+

Review Comment:
   **minor** — partitionSpecIndexMinSize = 32 is not supported by the 
benchmarks its own comment cites
   
   The comment says '32 is a conservative cutoff; keep it aligned with the 
lookup benchmarks', but at exactly 32 specs the map gives no measurable hit 
benefit and makes misses materially slower (a miss pays the map probe and then 
the full scan, by design). The first size where hits demonstrably win is 64. 
Either raise the constant to 64 or drop the claim that it tracks the 
benchmarks. No correctness impact and no regression versus main - head beats 
base at every size >= 32.



##########
table/metadata.go:
##########
@@ -2292,23 +2425,34 @@ func (c *commonMetadata) DefaultPartitionSpec() int {
        return c.DefaultSpecID
 }
 
+func (c *commonMetadata) partitionSpecIndexForLookup() *partitionSpecIndexData 
{
+       index := c.partitionSpecIndex

Review Comment:
   **nit** — Single-spec lookups are ~0.5 ns slower than base
   
   The extra partitionSpecIndexForLookup + partitionSpecIndexNeedsRebuild calls 
cost about half a nanosecond on the most common table shape. Statistically 
strong but practically irrelevant next to the 96 B / 2 allocs of the defensive 
clonePartitionSpec on the hit path. Noted only so the description's framing 
doesn't imply a strict improvement everywhere; no action needed.



##########
table/partition_spec_index_test.go:
##########
@@ -0,0 +1,430 @@
+// 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 (
+       "encoding/json"
+       "sync"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// Why: metadata lookups must use the derived ID index without changing the
+// existing default-spec fallback or missing-ID behavior.
+// Condition: metadata contains non-sequential partition spec IDs and the
+// index is initialized from the same slice.
+// Assertion: first, default, and missing lookups return the expected values.
+func TestCommonMetadataPartitionSpecIndexLookups(t *testing.T) {
+       specs := 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 0, 7, 42)
+       metadata := commonMetadata{
+               Specs:              specs,
+               DefaultSpecID:      42,
+               partitionSpecIndex: buildPartitionSpecIndex(specs),
+       }
+
+       byID := metadata.PartitionSpecByID(7)
+       require.NotNil(t, byID)
+       assert.Equal(t, 7, byID.ID())
+
+       defaultSpec := metadata.PartitionSpec()
+       assert.Equal(t, 42, defaultSpec.ID())
+       assert.Nil(t, metadata.PartitionSpecByID(99))
+}
+
+// Why: small metadata should not allocate an index that its linear lookup path
+// will never read.
+// Condition: parse a valid metadata document containing one partition spec.
+// Assertion: the decoded common metadata keeps only the slice identity.
+func TestParsedMetadataSkipsSmallPartitionSpecIndex(t *testing.T) {
+       metadata, err := ParseMetadataBytes([]byte(ExampleTableMetadataV2))
+       require.NoError(t, err)
+
+       common := metadataCommon(metadata)
+       require.Nil(t, common.partitionSpecIndex)
+}
+
+// Why: builders created from existing metadata should use the same small-slice
+// lookup policy as parsed metadata.
+// Condition: create a builder from parsed metadata and look up its final spec.
+// Assertion: the lookup works without allocating a map for one spec.
+func TestMetadataBuilderFromBaseSkipsSmallPartitionSpecIndex(t *testing.T) {
+       metadata, err := ParseMetadataBytes([]byte(ExampleTableMetadataV2))
+       require.NoError(t, err)
+
+       builder, err := MetadataBuilderFromBase(metadata, "")
+       require.NoError(t, err)
+       require.Nil(t, builder.partitionSpecIndex)
+
+       id := builder.specs[len(builder.specs)-1].ID()
+       spec, err := builder.GetSpecByID(id)
+       require.NoError(t, err)
+       require.NotNil(t, spec)
+       assert.Equal(t, id, spec.ID())
+}
+
+// Why: in-package fixtures can replace metadata slices directly, so a stale
+// derived index must not return a spec at the old position or hide a new one.
+// Condition: the indexed slice is replaced with another slice of equal length.
+// Assertion: lookups use the replacement slice without mutating the cached
+// index.
+func TestCommonMetadataPartitionSpecIndexFallsBackAfterSliceReplacement(t 
*testing.T) {
+       specs := 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 1, 2)
+       metadata := commonMetadata{
+               Specs:              specs,
+               DefaultSpecID:      2,
+               partitionSpecIndex: buildPartitionSpecIndex(specs),
+       }
+       originalIndex := metadata.partitionSpecIndex
+
+       metadata.Specs = 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 3, 4)
+       metadata.DefaultSpecID = 4
+       byID := metadata.PartitionSpecByID(4)
+       require.NotNil(t, byID)
+       assert.Equal(t, 4, byID.ID())
+       defaultSpec := metadata.PartitionSpec()
+       assert.Equal(t, 4, defaultSpec.ID())
+       assert.Nil(t, metadata.PartitionSpecByID(2))
+       assert.Same(t, originalIndex, metadata.partitionSpecIndex)
+       assert.Equal(t, 0, metadata.partitionSpecIndex.positions[1])
+       assert.Equal(t, 1, metadata.partitionSpecIndex.positions[2])
+}
+
+func 
TestCommonMetadataPartitionSpecIndexFallsBackAfterSliceReplacementConcurrent(t 
*testing.T) {
+       metadata := &commonMetadata{
+               Specs:              
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 1, 2),
+               partitionSpecIndex: 
buildPartitionSpecIndex(partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8,
 1, 2)),
+       }
+       metadata.Specs = 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 3, 4)
+       originalIndex := metadata.partitionSpecIndex
+
+       const goroutineCount = 8
+       var wg sync.WaitGroup
+       wg.Add(goroutineCount)
+       for range goroutineCount {
+               go func() {
+                       defer wg.Done()
+                       for range 100 {
+                               if spec := metadata.PartitionSpecByID(4); spec 
== nil || spec.ID() != 4 {
+                                       t.Errorf("expected partition spec 4, 
got %v", spec)
+                               }
+                       }
+               }()
+       }
+       wg.Wait()
+
+       assert.Same(t, originalIndex, metadata.partitionSpecIndex)
+}
+
+func TestRejectsDuplicatePartitionSpecIDs(t *testing.T) {
+       var raw map[string]json.RawMessage
+       require.NoError(t, json.Unmarshal([]byte(ExampleTableMetadataV2), &raw))
+
+       var specs []json.RawMessage
+       require.NoError(t, json.Unmarshal(raw["partition-specs"], &specs))
+       specs = append(specs, specs[0])
+       encodedSpecs, err := json.Marshal(specs)
+       require.NoError(t, err)
+       raw["partition-specs"] = encodedSpecs
+       data, err := json.Marshal(raw)
+       require.NoError(t, err)
+
+       _, err = ParseMetadataBytes(data)
+       require.ErrorIs(t, err, ErrInvalidMetadata)
+       assert.ErrorContains(t, err, "duplicate partition spec ID 0")
+}
+
+// Why: builder-produced metadata must enforce the same unique partition spec
+// ID invariant as metadata read from JSON.
+// Condition: an in-package builder is given duplicate spec IDs before Build.
+// Assertion: Build rejects the metadata instead of publishing an ambiguous 
index.
+func TestMetadataBuilderBuildRejectsDuplicatePartitionSpecIDs(t *testing.T) {
+       builder := builderWithoutChanges(2)
+       builder.specs = append(builder.specs, builder.specs[0])
+
+       _, err := builder.Build()
+       require.ErrorIs(t, err, ErrInvalidMetadata)
+       assert.ErrorContains(t, err, "duplicate partition spec ID 0")
+}
+
+// Why: in-package fixtures can mutate an existing spec slice without changing
+// its length or backing array, which cannot be detected by index metadata 
alone.
+// Condition: a spec is replaced in place after the index is built.
+// Assertion: lookup still finds the replacement and does not return the old 
ID.
+func TestCommonMetadataPartitionSpecIndexFallsBackAfterElementMutation(t 
*testing.T) {
+       specs := 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 1, 2)
+       oldID := specs[7].ID()
+       metadata := commonMetadata{
+               Specs:              specs,
+               DefaultSpecID:      1,
+               partitionSpecIndex: buildPartitionSpecIndex(specs),
+       }
+
+       specs[7] = iceberg.NewPartitionSpecID(3)
+
+       byID := metadata.PartitionSpecByID(3)
+       require.NotNil(t, byID)
+       assert.Equal(t, 3, byID.ID())
+       assert.Nil(t, metadata.PartitionSpecByID(oldID))
+}
+
+// Why: builders can also be used by package-level fixtures that replace their
+// spec slice without updating derived state.
+// Condition: an indexed builder receives a replacement slice of equal length.
+// Assertion: GetSpecByID resolves the replacement slice and refreshes its 
index.
+func TestMetadataBuilderPartitionSpecIndexFallsBackAfterSliceReplacement(t 
*testing.T) {
+       specs := 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 1, 2)
+       builder := MetadataBuilder{
+               specs:              specs,
+               partitionSpecIndex: buildPartitionSpecIndex(specs),
+       }
+       originalIndex := builder.partitionSpecIndex
+
+       builder.specs = 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 3, 4)
+       byID, err := builder.GetSpecByID(4)
+       require.NoError(t, err)
+       require.NotNil(t, byID)
+       assert.Equal(t, 4, byID.ID())
+       _, err = builder.GetSpecByID(2)
+       assert.ErrorIs(t, err, ErrPartitionSpecNotFound)
+       assert.NotSame(t, originalIndex, builder.partitionSpecIndex)
+       assert.Equal(t, 0, builder.partitionSpecIndex.positions[3])
+       assert.Equal(t, 1, builder.partitionSpecIndex.positions[4])
+       assert.Equal(t, 0, originalIndex.positions[1])
+       assert.Equal(t, 1, originalIndex.positions[2])
+}
+
+// Why: builder updates and clones must keep the spec index aligned without
+// sharing mutable lookup state with a built metadata value or sibling builder.
+// Condition: add a spec, clone the builder, add another spec to the clone, and
+// remove the first added spec from the original.
+// Assertion: each builder resolves the IDs in its own current spec slice.
+func TestMetadataBuilderPartitionSpecIndexFollowsUpdates(t *testing.T) {
+       builder := builderWithoutChanges(2)
+       for id := 1; len(builder.specs) < partitionSpecIndexMinSize; id++ {
+               builder.specs = append(builder.specs, 
iceberg.NewPartitionSpecID(id))
+       }
+       builder.partitionSpecIndex = buildPartitionSpecIndex(builder.specs)
+       require.Len(t, builder.specs, partitionSpecIndexMinSize)
+       require.Equal(t, 0, builder.partitionSpecIndex.positions[0])
+
+       added := iceberg.NewPartitionSpecID(99, iceberg.PartitionField{
+               SourceIDs: []int{1}, Name: "x", Transform: 
iceberg.IdentityTransform{},
+       })
+       require.NoError(t, builder.AddPartitionSpec(&added, false))
+       require.Equal(t, partitionSpecIndexMinSize, 
builder.partitionSpecIndex.positions[partitionSpecIndexMinSize])
+
+       got, err := builder.GetSpecByID(partitionSpecIndexMinSize)
+       require.NoError(t, err)
+       require.NotNil(t, got)
+       assert.Equal(t, partitionSpecIndexMinSize, got.ID())
+
+       clone := builder.clone()
+       cloneAdded := iceberg.NewPartitionSpecID(99, iceberg.PartitionField{
+               SourceIDs: []int{3}, Name: "z", Transform: 
iceberg.IdentityTransform{},
+       })
+       require.NoError(t, clone.AddPartitionSpec(&cloneAdded, false))
+       assert.NotContains(t, builder.partitionSpecIndex.positions, 
partitionSpecIndexMinSize+1)
+       assert.Equal(t, partitionSpecIndexMinSize+1, 
clone.partitionSpecIndex.positions[partitionSpecIndexMinSize+1])
+
+       require.NoError(t, builder.RemovePartitionSpecs([]int{1}))
+       assert.NotContains(t, builder.partitionSpecIndex.positions, 1)
+       _, err = builder.GetSpecByID(1)
+       assert.ErrorIs(t, err, ErrPartitionSpecNotFound)
+       got, err = clone.GetSpecByID(1)
+       require.NoError(t, err)
+       assert.Equal(t, 1, got.ID())
+}
+
+func TestMetadataBuilderPartitionSpecIndexBuildsAtThreshold(t *testing.T) {
+       builder := builderWithoutChanges(2)
+       for id := 1; len(builder.specs) < partitionSpecIndexMinSize-1; id++ {
+               builder.specs = append(builder.specs, 
iceberg.NewPartitionSpecID(id))
+       }
+       builder.partitionSpecIndex = buildPartitionSpecIndex(builder.specs)
+       require.Nil(t, builder.partitionSpecIndex)
+
+       added := iceberg.NewPartitionSpecID(99, iceberg.PartitionField{
+               SourceIDs: []int{1}, Name: "threshold", Transform: 
iceberg.IdentityTransform{},
+       })
+       require.NoError(t, builder.AddPartitionSpec(&added, false))
+       require.NotNil(t, builder.partitionSpecIndex.positions)
+       assert.Equal(t, partitionSpecIndexMinSize-1,
+               
builder.partitionSpecIndex.positions[partitionSpecIndexMinSize-1])
+}
+
+// Why: removing unknown IDs is a no-op and must not leave the derived index
+// pointing at a newly allocated but unindexed slice.
+// Condition: remove an ID that is not present in the builder.
+// Assertion: the specs slice and its index remain unchanged.
+func TestMetadataBuilderRemoveUnknownPartitionSpecKeepsIndex(t *testing.T) {
+       builder := builderWithoutChanges(2)
+       originalIndex := builder.partitionSpecIndex
+       originalFirst := &builder.specs[0]
+
+       require.NoError(t, builder.RemovePartitionSpecs([]int{99}))
+       assert.Same(t, originalIndex, builder.partitionSpecIndex)
+       assert.Same(t, originalFirst, &builder.specs[0])
+}
+
+// Why: a built metadata value shares the derived index with its builder until
+// the builder mutates its spec list.
+// Condition: build metadata, then add a new partition spec to the builder.
+// Assertion: the builder sees the new spec while the already-built metadata
+// remains unchanged.
+func TestMetadataBuilderPartitionSpecIndexIsolatedFromBuiltMetadata(t 
*testing.T) {
+       builder := builderWithoutChanges(2)
+       for id := 1; len(builder.specs) < partitionSpecIndexMinSize; id++ {
+               builder.specs = append(builder.specs, 
iceberg.NewPartitionSpecID(id))
+       }
+       builder.partitionSpecIndex = buildPartitionSpecIndex(builder.specs)
+       metadata, err := builder.Build()
+       require.NoError(t, err)
+       common := metadataCommon(metadata)
+       originalIndex := common.partitionSpecIndex
+
+       added := iceberg.NewPartitionSpecID(99, iceberg.PartitionField{
+               SourceIDs: []int{3}, Name: "z", Transform: 
iceberg.IdentityTransform{},
+       })
+       require.NoError(t, builder.AddPartitionSpec(&added, false))
+
+       assert.NotSame(t, originalIndex, builder.partitionSpecIndex)
+       assert.NotContains(t, originalIndex.positions, 
partitionSpecIndexMinSize)
+       assert.Contains(t, builder.partitionSpecIndex.positions, 
partitionSpecIndexMinSize)
+       assert.Nil(t, common.PartitionSpecByID(partitionSpecIndexMinSize))
+       got, err := builder.GetSpecByID(1)
+       require.NoError(t, err)
+       assert.Equal(t, 1, got.ID())
+}
+
+func TestCommonMetadataPartitionSpecLookupsConcurrent(t *testing.T) {
+       specs := partitionSpecIndexTestSpecs(1, 2)

Review Comment:
   **minor** — TestCommonMetadataPartitionSpecLookupsConcurrent exercises none 
of the new index machinery
   
   The fixture is partitionSpecIndexTestSpecs(1, 2), which is below the 
construction gate, so metadata.partitionSpecIndex is nil, 
partitionSpecIndexForLookup returns immediately without rebuilding, and 
partitionSpecIndexPosition's map branch is skipped. The test only proves 
concurrent linear scans are safe - which the Go memory model already gives for 
free. This is the one test from `laskoviymishka`'s round-1 comment that wasn't 
lifted above the threshold (its sibling ...CopyOnWriteConcurrent was, and has 
bite). Size it from partitionSpecIndexMinSize like the other fixtures, or 
rename/comment it to say it covers unindexed concurrent reads.



##########
table/metadata.go:
##########
@@ -2703,11 +2848,25 @@ func (c *commonMetadata) checkSchemas() error {
 }
 
 func (c *commonMetadata) checkPartitionSpecs() error {
-       for _, spec := range c.Specs {
-               if spec.ID() == c.DefaultSpecID {
-                       return nil
+       // Partition spec IDs are unique in persisted metadata. Keep this 
validation
+       // aligned with the schema and snapshot ID checks so normal read paths 
never
+       // need to tolerate duplicate IDs.
+       seen := make(map[int]struct{}, len(c.Specs))
+       defaultFound := false
+       for i := range c.Specs {
+               id := c.Specs[i].ID()
+               if _, ok := seen[id]; ok {
+                       return fmt.Errorf("%w: duplicate partition spec ID %d", 
ErrInvalidMetadata, id)
+               }
+               seen[id] = struct{}{}
+

Review Comment:
   **nit** — Description omits the threshold, the loop rewrite, and the new 
load-time hard failure
   
   Three gaps worth closing before merge: (a) the 32 threshold - and the fact 
the index is not even built below it - is never named, only 'Small slices use 
the existing linear-scan path'; (b) no mention of the 'for _, s := range' -> 
'for i := range' rewrite, which now supplies 100% of the win for realistic 
single-digit spec counts; (c) rejecting duplicate partition spec IDs turns 
previously-loadable metadata into a hard ErrInvalidMetadata at parse and at 
Build - correct and matching Java per the settled design discussion, but a 
user-visible compatibility change that deserves a release note. Error 
precedence also changed within the same sentinel.



##########
table/partition_spec_index_test.go:
##########
@@ -0,0 +1,430 @@
+// 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 (
+       "encoding/json"
+       "sync"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// Why: metadata lookups must use the derived ID index without changing the
+// existing default-spec fallback or missing-ID behavior.
+// Condition: metadata contains non-sequential partition spec IDs and the
+// index is initialized from the same slice.
+// Assertion: first, default, and missing lookups return the expected values.
+func TestCommonMetadataPartitionSpecIndexLookups(t *testing.T) {
+       specs := 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 0, 7, 42)
+       metadata := commonMetadata{
+               Specs:              specs,
+               DefaultSpecID:      42,
+               partitionSpecIndex: buildPartitionSpecIndex(specs),
+       }
+
+       byID := metadata.PartitionSpecByID(7)
+       require.NotNil(t, byID)
+       assert.Equal(t, 7, byID.ID())
+
+       defaultSpec := metadata.PartitionSpec()
+       assert.Equal(t, 42, defaultSpec.ID())
+       assert.Nil(t, metadata.PartitionSpecByID(99))
+}
+
+// Why: small metadata should not allocate an index that its linear lookup path
+// will never read.
+// Condition: parse a valid metadata document containing one partition spec.
+// Assertion: the decoded common metadata keeps only the slice identity.
+func TestParsedMetadataSkipsSmallPartitionSpecIndex(t *testing.T) {
+       metadata, err := ParseMetadataBytes([]byte(ExampleTableMetadataV2))
+       require.NoError(t, err)
+
+       common := metadataCommon(metadata)
+       require.Nil(t, common.partitionSpecIndex)
+}
+
+// Why: builders created from existing metadata should use the same small-slice
+// lookup policy as parsed metadata.
+// Condition: create a builder from parsed metadata and look up its final spec.
+// Assertion: the lookup works without allocating a map for one spec.
+func TestMetadataBuilderFromBaseSkipsSmallPartitionSpecIndex(t *testing.T) {
+       metadata, err := ParseMetadataBytes([]byte(ExampleTableMetadataV2))
+       require.NoError(t, err)
+
+       builder, err := MetadataBuilderFromBase(metadata, "")
+       require.NoError(t, err)
+       require.Nil(t, builder.partitionSpecIndex)
+
+       id := builder.specs[len(builder.specs)-1].ID()
+       spec, err := builder.GetSpecByID(id)
+       require.NoError(t, err)
+       require.NotNil(t, spec)
+       assert.Equal(t, id, spec.ID())
+}
+
+// Why: in-package fixtures can replace metadata slices directly, so a stale
+// derived index must not return a spec at the old position or hide a new one.
+// Condition: the indexed slice is replaced with another slice of equal length.
+// Assertion: lookups use the replacement slice without mutating the cached
+// index.
+func TestCommonMetadataPartitionSpecIndexFallsBackAfterSliceReplacement(t 
*testing.T) {
+       specs := 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 1, 2)
+       metadata := commonMetadata{
+               Specs:              specs,
+               DefaultSpecID:      2,
+               partitionSpecIndex: buildPartitionSpecIndex(specs),
+       }
+       originalIndex := metadata.partitionSpecIndex
+
+       metadata.Specs = 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 3, 4)
+       metadata.DefaultSpecID = 4
+       byID := metadata.PartitionSpecByID(4)
+       require.NotNil(t, byID)
+       assert.Equal(t, 4, byID.ID())
+       defaultSpec := metadata.PartitionSpec()
+       assert.Equal(t, 4, defaultSpec.ID())
+       assert.Nil(t, metadata.PartitionSpecByID(2))
+       assert.Same(t, originalIndex, metadata.partitionSpecIndex)
+       assert.Equal(t, 0, metadata.partitionSpecIndex.positions[1])
+       assert.Equal(t, 1, metadata.partitionSpecIndex.positions[2])
+}
+
+func 
TestCommonMetadataPartitionSpecIndexFallsBackAfterSliceReplacementConcurrent(t 
*testing.T) {
+       metadata := &commonMetadata{
+               Specs:              
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 1, 2),
+               partitionSpecIndex: 
buildPartitionSpecIndex(partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8,
 1, 2)),
+       }
+       metadata.Specs = 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 3, 4)
+       originalIndex := metadata.partitionSpecIndex
+
+       const goroutineCount = 8
+       var wg sync.WaitGroup
+       wg.Add(goroutineCount)
+       for range goroutineCount {
+               go func() {
+                       defer wg.Done()
+                       for range 100 {
+                               if spec := metadata.PartitionSpecByID(4); spec 
== nil || spec.ID() != 4 {
+                                       t.Errorf("expected partition spec 4, 
got %v", spec)
+                               }
+                       }
+               }()
+       }
+       wg.Wait()
+
+       assert.Same(t, originalIndex, metadata.partitionSpecIndex)
+}
+
+func TestRejectsDuplicatePartitionSpecIDs(t *testing.T) {
+       var raw map[string]json.RawMessage
+       require.NoError(t, json.Unmarshal([]byte(ExampleTableMetadataV2), &raw))
+
+       var specs []json.RawMessage
+       require.NoError(t, json.Unmarshal(raw["partition-specs"], &specs))
+       specs = append(specs, specs[0])
+       encodedSpecs, err := json.Marshal(specs)
+       require.NoError(t, err)
+       raw["partition-specs"] = encodedSpecs
+       data, err := json.Marshal(raw)
+       require.NoError(t, err)
+
+       _, err = ParseMetadataBytes(data)
+       require.ErrorIs(t, err, ErrInvalidMetadata)
+       assert.ErrorContains(t, err, "duplicate partition spec ID 0")
+}
+
+// Why: builder-produced metadata must enforce the same unique partition spec
+// ID invariant as metadata read from JSON.
+// Condition: an in-package builder is given duplicate spec IDs before Build.
+// Assertion: Build rejects the metadata instead of publishing an ambiguous 
index.
+func TestMetadataBuilderBuildRejectsDuplicatePartitionSpecIDs(t *testing.T) {
+       builder := builderWithoutChanges(2)
+       builder.specs = append(builder.specs, builder.specs[0])
+
+       _, err := builder.Build()
+       require.ErrorIs(t, err, ErrInvalidMetadata)
+       assert.ErrorContains(t, err, "duplicate partition spec ID 0")
+}
+
+// Why: in-package fixtures can mutate an existing spec slice without changing
+// its length or backing array, which cannot be detected by index metadata 
alone.
+// Condition: a spec is replaced in place after the index is built.
+// Assertion: lookup still finds the replacement and does not return the old 
ID.
+func TestCommonMetadataPartitionSpecIndexFallsBackAfterElementMutation(t 
*testing.T) {
+       specs := 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 1, 2)
+       oldID := specs[7].ID()
+       metadata := commonMetadata{
+               Specs:              specs,
+               DefaultSpecID:      1,
+               partitionSpecIndex: buildPartitionSpecIndex(specs),
+       }
+
+       specs[7] = iceberg.NewPartitionSpecID(3)
+
+       byID := metadata.PartitionSpecByID(3)
+       require.NotNil(t, byID)
+       assert.Equal(t, 3, byID.ID())
+       assert.Nil(t, metadata.PartitionSpecByID(oldID))
+}
+
+// Why: builders can also be used by package-level fixtures that replace their
+// spec slice without updating derived state.
+// Condition: an indexed builder receives a replacement slice of equal length.
+// Assertion: GetSpecByID resolves the replacement slice and refreshes its 
index.
+func TestMetadataBuilderPartitionSpecIndexFallsBackAfterSliceReplacement(t 
*testing.T) {
+       specs := 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 1, 2)
+       builder := MetadataBuilder{
+               specs:              specs,
+               partitionSpecIndex: buildPartitionSpecIndex(specs),
+       }
+       originalIndex := builder.partitionSpecIndex
+
+       builder.specs = 
partitionSpecIndexTestSpecsAtLeast(partitionSpecIndexMinSize+8, 3, 4)
+       byID, err := builder.GetSpecByID(4)
+       require.NoError(t, err)
+       require.NotNil(t, byID)
+       assert.Equal(t, 4, byID.ID())
+       _, err = builder.GetSpecByID(2)
+       assert.ErrorIs(t, err, ErrPartitionSpecNotFound)
+       assert.NotSame(t, originalIndex, builder.partitionSpecIndex)
+       assert.Equal(t, 0, builder.partitionSpecIndex.positions[3])
+       assert.Equal(t, 1, builder.partitionSpecIndex.positions[4])
+       assert.Equal(t, 0, originalIndex.positions[1])
+       assert.Equal(t, 1, originalIndex.positions[2])
+}
+
+// Why: builder updates and clones must keep the spec index aligned without
+// sharing mutable lookup state with a built metadata value or sibling builder.
+// Condition: add a spec, clone the builder, add another spec to the clone, and
+// remove the first added spec from the original.
+// Assertion: each builder resolves the IDs in its own current spec slice.
+func TestMetadataBuilderPartitionSpecIndexFollowsUpdates(t *testing.T) {
+       builder := builderWithoutChanges(2)
+       for id := 1; len(builder.specs) < partitionSpecIndexMinSize; id++ {
+               builder.specs = append(builder.specs, 
iceberg.NewPartitionSpecID(id))
+       }
+       builder.partitionSpecIndex = buildPartitionSpecIndex(builder.specs)
+       require.Len(t, builder.specs, partitionSpecIndexMinSize)
+       require.Equal(t, 0, builder.partitionSpecIndex.positions[0])
+
+       added := iceberg.NewPartitionSpecID(99, iceberg.PartitionField{
+               SourceIDs: []int{1}, Name: "x", Transform: 
iceberg.IdentityTransform{},
+       })
+       require.NoError(t, builder.AddPartitionSpec(&added, false))
+       require.Equal(t, partitionSpecIndexMinSize, 
builder.partitionSpecIndex.positions[partitionSpecIndexMinSize])
+
+       got, err := builder.GetSpecByID(partitionSpecIndexMinSize)
+       require.NoError(t, err)
+       require.NotNil(t, got)
+       assert.Equal(t, partitionSpecIndexMinSize, got.ID())
+
+       clone := builder.clone()
+       cloneAdded := iceberg.NewPartitionSpecID(99, iceberg.PartitionField{
+               SourceIDs: []int{3}, Name: "z", Transform: 
iceberg.IdentityTransform{},
+       })
+       require.NoError(t, clone.AddPartitionSpec(&cloneAdded, false))
+       assert.NotContains(t, builder.partitionSpecIndex.positions, 
partitionSpecIndexMinSize+1)
+       assert.Equal(t, partitionSpecIndexMinSize+1, 
clone.partitionSpecIndex.positions[partitionSpecIndexMinSize+1])
+
+       require.NoError(t, builder.RemovePartitionSpecs([]int{1}))
+       assert.NotContains(t, builder.partitionSpecIndex.positions, 1)
+       _, err = builder.GetSpecByID(1)
+       assert.ErrorIs(t, err, ErrPartitionSpecNotFound)
+       got, err = clone.GetSpecByID(1)
+       require.NoError(t, err)
+       assert.Equal(t, 1, got.ID())
+}
+
+func TestMetadataBuilderPartitionSpecIndexBuildsAtThreshold(t *testing.T) {
+       builder := builderWithoutChanges(2)
+       for id := 1; len(builder.specs) < partitionSpecIndexMinSize-1; id++ {
+               builder.specs = append(builder.specs, 
iceberg.NewPartitionSpecID(id))
+       }
+       builder.partitionSpecIndex = buildPartitionSpecIndex(builder.specs)
+       require.Nil(t, builder.partitionSpecIndex)
+
+       added := iceberg.NewPartitionSpecID(99, iceberg.PartitionField{
+               SourceIDs: []int{1}, Name: "threshold", Transform: 
iceberg.IdentityTransform{},
+       })
+       require.NoError(t, builder.AddPartitionSpec(&added, false))
+       require.NotNil(t, builder.partitionSpecIndex.positions)
+       assert.Equal(t, partitionSpecIndexMinSize-1,
+               
builder.partitionSpecIndex.positions[partitionSpecIndexMinSize-1])
+}
+
+// Why: removing unknown IDs is a no-op and must not leave the derived index
+// pointing at a newly allocated but unindexed slice.
+// Condition: remove an ID that is not present in the builder.
+// Assertion: the specs slice and its index remain unchanged.
+func TestMetadataBuilderRemoveUnknownPartitionSpecKeepsIndex(t *testing.T) {
+       builder := builderWithoutChanges(2)

Review Comment:
   **nit** — Half of TestMetadataBuilderRemoveUnknownPartitionSpecKeepsIndex is 
a nil-vs-nil assertion
   
   builderWithoutChanges(2) has 1 spec, so originalIndex and 
builder.partitionSpecIndex are both nil and assert.Same compares nil to nil. 
The following assert.Same(&builder.specs[0]) is the assertion carrying the 
test's meaning. Consider dropping the index assertion or moving the fixture 
above the threshold.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to