Skip to content

fix(array): validate run-end encoded invariants#992

Merged
zeroshade merged 3 commits into
apache:mainfrom
fallintoplace:fix/validate-run-end-encoded
Jul 24, 2026
Merged

fix(array): validate run-end encoded invariants#992
zeroshade merged 3 commits into
apache:mainfrom
fallintoplace:fix/validate-run-end-encoded

Conversation

@fallintoplace

Copy link
Copy Markdown
Contributor

Summary

  • add RunEndEncoded Validate and ValidateFull implementations
  • validate parent and child invariants, logical coverage, and strict run-end monotonicity
  • add regression tests for short coverage and duplicate run ends

Why

Run-end encoded helpers already assume that run ends are strictly increasing and cover the logical range. Today malformed arrays can pass validation even when those invariants do not hold.

Validation

  • go test ./arrow/array ./arrow/encoded

@fallintoplace
fallintoplace requested a review from zeroshade as a code owner July 23, 2026 21:32

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

Can we add a test that goes through the array.Validate/array.ValidateFull functions also?

Comment thread arrow/array/encoded.go Outdated
Comment on lines +282 to +290
runEnds := getRunEnds64(runEndsData)
lastRunEnd := runEnds[len(runEnds)-1]
if lastRunEnd < int64(r.data.offset+r.data.length) {
return fmt.Errorf("arrow/array: last run end is %d but it should cover %d", lastRunEnd, r.data.offset+r.data.length)
}

if !full {
return nil
}

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.

In the !full path, we're copying the entirety of the run ends only to read the single runEnds[len-1]. Instead for the !full path we should only check the last run end instead of doing the full copy loop

Comment thread arrow/array/encoded.go Outdated
Comment on lines +326 to +345
case arrow.INT16:
runEnds := arrow.Int16Traits.CastFromBytes(data.Buffers()[1].Bytes())
runEnds = runEnds[data.Offset() : data.Offset()+data.Len()]
out := make([]int64, len(runEnds))
for i, v := range runEnds {
out[i] = int64(v)
}
return out
case arrow.INT32:
runEnds := arrow.Int32Traits.CastFromBytes(data.Buffers()[1].Bytes())
runEnds = runEnds[data.Offset() : data.Offset()+data.Len()]
out := make([]int64, len(runEnds))
for i, v := range runEnds {
out[i] = int64(v)
}
return out
default:
runEnds := arrow.Int64Traits.CastFromBytes(data.Buffers()[1].Bytes())
return runEnds[data.Offset() : data.Offset()+data.Len()]
}

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.

at this point we haven't validated the length/buffers yet, so we need to check len(runEnds) before you start slicing using data.Offset()+data.Len() otherwise we could panic on a corrupt buffer.

Comment thread arrow/array/encoded.go Outdated
func runEndTypeLimit(id arrow.Type) int64 {
switch id {
case arrow.INT16:
return 1<<15 - 1

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.

math.MaxInt16 ?

Comment thread arrow/array/encoded.go Outdated
}
}

func getRunEnds64(data arrow.ArrayData) []int64 {

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.

there's already a encoded.getRunEnds function at ree_utils.go:109 which returns a non-allocating closure, could we use export/reuse that pattern instead of allocating a new []int64?

@zeroshade
zeroshade merged commit 22bec19 into apache:main Jul 24, 2026
23 checks passed
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.

2 participants