Skip to content

fix(schema): prevent AsStruct aliasing#1415

Open
fallintoplace wants to merge 1 commit into
apache:mainfrom
fallintoplace:fix/schema-asstruct-clone
Open

fix(schema): prevent AsStruct aliasing#1415
fallintoplace wants to merge 1 commit into
apache:mainfrom
fallintoplace:fix/schema-asstruct-clone

Conversation

@fallintoplace

Copy link
Copy Markdown
Contributor

What changed

  • Make Schema.AsStruct() return a deep-cloned StructType field list instead of exposing internal schema state.
  • Add deep-copy helpers for nested struct/list/map types to prevent mutable aliasing through returned nested types.
  • Add regression tests covering top-level and nested field aliasing.

This ensures callers cannot mutate schema internals through Schema.AsStruct().

@fallintoplace
fallintoplace requested a review from zeroshade as a code owner July 5, 2026 17:32

@tanmayrauth tanmayrauth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix is correct and the tests nail the aliasing. My one real concern is that AsStruct is on the scan/visitor hot paths and none of those callers mutate — so this makes them all deep-clone for nothing. Details inline.

Comment thread schema.go Outdated
// AsStruct returns a Struct with the same fields as the schema which can
// then be used as a Type.
func (s *Schema) AsStruct() StructType { return StructType{FieldList: s.fields} }
func (s *Schema) AsStruct() StructType { return StructType{FieldList: cloneFields(s.fields)} }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

func (s *Schema) AsStruct() StructType { return StructType{FieldList: cloneFields(s.fields)} }

This method isn't only a public convenience — it's called on internal hot paths that never mutate the result: the schema visitors (Visit at schema.go:599, PreOrderVisit at schema.go:770, visitStructWithPartner at schema.go:1485) and the metrics evaluator constructors (table/evaluators.go:713, :728, :1247). Those evaluators are built per-manifest/per-file during scan planning (table/scanner.go, arrow_scanner.go:955), and the visitors run on every projection/prune/promote pass. All of them just read FieldList.

With this change every one of them now recursively clones the whole schema tree on each call, which is a meaningful regression for wide/deeply-nested schemas during scans. Could we keep an internal non-cloning path (callers use s.fields directly, or a private asStructRef()) and only clone in the exported AsStruct()? That preserves the public-safety guarantee without taxing the read-only internal callers.

Comment thread schema.go
return cloned
}

func cloneType(t Type) Type {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

func cloneType(t Type) Type {

The default branch returns t unchanged, so leaf/parameterized types (Decimal, Fixed, Geometry, Geography, Variant, ...) stay aliased — only the container types are deep-copied. I think that's intentional since the package treats those as immutable, but a one-line comment saying so would keep the partial clone from reading as a missed case.

Comment thread schema_test.go
Name: "person",
Type: &iceberg.StructType{FieldList: []iceberg.NestedField{
{ID: 2, Name: "name", Type: iceberg.PrimitiveTypes.String},
}},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice coverage on the struct case. Since cloneType also special-cases *ListType and *MapType (schema.go:337, :342), could you add a nested list and map aliasing test too? Otherwise a future tweak to those branches could regress without a failing test.

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The aliasing fix is right in principle, but the current placement is a scan-path performance regression (inline). Also missing *ListType/*MapType aliasing tests — the added tests only cover the top-level field list and nested *StructType.

Comment thread schema.go
// then be used as a Type.
func (s *Schema) AsStruct() StructType { return StructType{FieldList: s.fields} }
func (s *Schema) AsStruct() StructType { return StructType{FieldList: cloneFields(s.fields)} }
func (s *Schema) NumFields() int { return len(s.fields) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AsStruct() is on the read-only scan critical path (called by schema visitors and evaluator constructors that never mutate the result). Deep-cloning the full field tree on every call is a real regression for wide/nested schemas. Suggest a shared/private asStructRef() for internal read-only callers, keeping the deep clone only in the exported AsStruct().

Comment thread schema.go
cloned.ValueType = cloneType(typed.ValueType)

return &cloned
default:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default branch returns t unchanged (primitive/Decimal/Fixed/Geometry/Geography/Variant) — likely intentional since these are immutable values, but a one-line comment would keep it from reading as a missed case.

@fallintoplace
fallintoplace force-pushed the fix/schema-asstruct-clone branch from 1710eca to 1425820 Compare July 9, 2026 17:53

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the latest commit — the hot-path concern is fully resolved:

  • AsStruct() still deep-clones for the public/safe path, but the internal read-only visitors now use the new non-cloning asStructRef(), and the evaluator constructors no longer call AsStruct() at all — so scan planning no longer pays for a full field-tree clone.
  • The cloneType default branch now has an explanatory comment.
  • Dedicated *ListType/*MapType aliasing tests were added.

LGTM — thanks for the quick turnaround!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants