laskoviymishka commented on code in PR #1628:
URL: https://github.com/apache/iceberg-go/pull/1628#discussion_r3719815211


##########
schema_test.go:
##########
@@ -570,6 +570,65 @@ func TestUnmarshalSchema(t *testing.T) {
        assert.True(t, tableSchemaSimple.Equals(&schema))
 }
 
+func TestUnmarshalSchemaReplacesExistingState(t *testing.T) {
+       schema := iceberg.NewSchemaWithIdentifiers(7, []int{1},
+               iceberg.NestedField{ID: 1, Name: "old", Type: 
iceberg.PrimitiveTypes.String},
+       )
+       _, ok := schema.FindFieldByID(1)
+       require.True(t, ok)
+       _, ok = schema.FindColumnName(1)
+       require.True(t, ok)
+       _, ok = schema.FindFieldByName("old")
+       require.True(t, ok)
+       _, ok = schema.FindFieldByNameCaseInsensitive("OLD")
+       require.True(t, ok)
+       assert.Contains(t, schema.NameMapping().String(), "old")
+
+       require.NoError(t, json.Unmarshal([]byte(`{
+               "type": "struct",
+               "fields": [{"id": 2, "name": "new", "type": "long", "required": 
true}]
+       }`), schema))
+
+       assert.Zero(t, schema.ID)
+       assert.Empty(t, schema.IdentifierFieldIDs)
+       assert.Equal(t, 1, schema.NumFields())
+       _, ok = schema.FindFieldByID(1)
+       assert.False(t, ok)
+       _, ok = schema.FindColumnName(1)
+       assert.False(t, ok)
+       _, ok = schema.FindFieldByName("old")
+       assert.False(t, ok)
+       _, ok = schema.FindFieldByNameCaseInsensitive("OLD")
+       assert.False(t, ok)
+       field, ok := schema.FindFieldByID(2)
+       require.True(t, ok)
+       assert.Equal(t, "new", field.Name)
+       assert.Contains(t, schema.NameMapping().String(), "new")
+       assert.NotContains(t, schema.NameMapping().String(), "old")
+}
+
+func TestUnmarshalSchemaPreservesExistingStateOnError(t *testing.T) {
+       schema := iceberg.NewSchemaWithIdentifiers(7, []int{1},
+               iceberg.NestedField{ID: 1, Name: "old", Type: 
iceberg.PrimitiveTypes.String},
+       )
+
+       err := json.Unmarshal([]byte(`{
+               "type": "struct",
+               "fields": [
+                       {"id": 2, "name": "first", "type": "long", "required": 
true},
+                       {"id": 2, "name": "duplicate", "type": "string", 
"required": false}
+               ]
+       }`), schema)
+       require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+
+       assert.Equal(t, 7, schema.ID)
+       assert.Equal(t, []int{1}, schema.IdentifierFieldIDs)
+       assert.Equal(t, 1, schema.NumFields())
+       field, ok := schema.FindFieldByID(1)

Review Comment:
   This doesn't actually prove the caches survive the error.
   
   `FindFieldByID` goes through `lazyIDToField`, which rebuilds from `s.fields` 
on a nil atomic. So even if the error path had wrongly zeroed the caches, this 
would still pass by recomputing from the unchanged `fields` slice, and the test 
can't tell "caches left intact" from "caches cleared but recomputed". Since 
stale cached lookups are exactly the bug we're guarding against, I'd warm every 
lookup (`FindFieldByID`, `FindFieldByName`, `FindColumnName`, 
`FindFieldByNameCaseInsensitive`, `NameMapping()`) before the failing 
unmarshal, then assert they still return the old field afterward.



##########
schema_test.go:
##########
@@ -570,6 +570,65 @@ func TestUnmarshalSchema(t *testing.T) {
        assert.True(t, tableSchemaSimple.Equals(&schema))
 }
 
+func TestUnmarshalSchemaReplacesExistingState(t *testing.T) {
+       schema := iceberg.NewSchemaWithIdentifiers(7, []int{1},
+               iceberg.NestedField{ID: 1, Name: "old", Type: 
iceberg.PrimitiveTypes.String},
+       )
+       _, ok := schema.FindFieldByID(1)
+       require.True(t, ok)
+       _, ok = schema.FindColumnName(1)
+       require.True(t, ok)
+       _, ok = schema.FindFieldByName("old")
+       require.True(t, ok)
+       _, ok = schema.FindFieldByNameCaseInsensitive("OLD")
+       require.True(t, ok)
+       assert.Contains(t, schema.NameMapping().String(), "old")

Review Comment:
   The pre-warm seeds `lazyNameMapping` and the four atomic caches, but skips 
`lazyIDToParent`, and that's the one cache reset by `init()` rather than an 
explicit `Store(nil)`, so it's the most likely to regress if `init()` ever 
changes.
   
   Could we seed it with `schema.FieldHasOptionalParent(1)` before the 
unmarshal, then assert `FieldHasOptionalParent(2)` is `false` after the reuse? 
wdyt?



##########
schema.go:
##########
@@ -290,13 +291,21 @@ func (s *Schema) UnmarshalJSON(b []byte) error {
                return err
        }
 
-       s.init()
-
-       s.fields = aux.Fields
-       if s.IdentifierFieldIDs == nil {
-               s.IdentifierFieldIDs = []int{}
+       decoded.fields = aux.Fields
+       if decoded.IdentifierFieldIDs == nil {
+               decoded.IdentifierFieldIDs = []int{}
        }
 
+       s.ID = decoded.ID
+       s.IdentifierFieldIDs = decoded.IdentifierFieldIDs
+       s.fields = decoded.fields
+       s.idToName.Store(nil)
+       s.idToField.Store(nil)
+       s.nameToID.Store(nil)
+       s.nameToIDLower.Store(nil)
+       s.idToAccessor.Store(nil)
+       s.init()

Review Comment:
   Small ordering thing: we write `s.fields`/`s.ID`/`s.IdentifierFieldIDs` 
first and then clear the five atomics. In the window between, a concurrent 
reader on a lazy-rebuild path can recompute an index from the new fields and 
store it, only for the `Store(nil)` to immediately throw it away. Harmless 
today, and mutating a `*Schema` concurrently is racy regardless, but the 
idiomatic order is clear the derived caches first, then overwrite the 
authoritative fields, then `init()`.
   
   While we're here: this clears 5 of the 7 cached values explicitly but leaves 
`lazyIDToParent` and `lazyNameMapping` to `init()`. That's correct, but 
non-obvious. A one-line comment noting those two are reset by `init()` because 
they're func values rather than atomics would save the next person from 
wondering why they're not in the `Store(nil)` list.



##########
schema.go:
##########
@@ -290,13 +291,21 @@ func (s *Schema) UnmarshalJSON(b []byte) error {
                return err
        }
 
-       s.init()
-
-       s.fields = aux.Fields
-       if s.IdentifierFieldIDs == nil {
-               s.IdentifierFieldIDs = []int{}
+       decoded.fields = aux.Fields
+       if decoded.IdentifierFieldIDs == nil {
+               decoded.IdentifierFieldIDs = []int{}
        }
 
+       s.ID = decoded.ID

Review Comment:
   This manual three-field copy is a bit of a trap. If someone later adds a new 
JSON-tagged field to `Schema`, they'll wire up the tag and `MarshalJSON` but 
won't know to update this block, so the value decodes into `decoded` and then 
gets silently dropped on the re-unmarshal path, with nothing at compile time to 
catch it.
   
   I'd add a comment right here spelling out the contract: 
decode-into-temp-then-copy is deliberate (validate before we touch the 
receiver, the same reason Java's `SchemaParser.fromJson` always returns a fresh 
`Schema`), and any new JSON-decodable field has to be copied here too. wdyt?



-- 
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