mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-23 14:30:46 +02:00
* iceberg: record geometry bounds on the data files compaction writes Compaction collected column statistics for every primitive column except geometry: the bound converter has no geometry case, so a compacted file's manifest entry carried value and null counts for a geometry column but no lower or upper bound. Readers that skip files by these bounds for a spatial filter (for example DuckDB for `geom && <box>`) had to open every compacted file, where the files before compaction could be skipped. The bound follows the spec's single-value serialization for geometry: the lower bound is the point (xmin, ymin), the upper bound (xmax, ymax), each an 8-byte little-endian double; z and m are not written. It is the union of the written file's row-group geospatial statistics, which parquet-go computes from every non-null value. A bound that is too small makes a reader skip rows, so any doubt gives none: a row group with non-null values but no geospatial statistics (parquet-go omits them when a value does not parse as WKB), a box that is not finite, an inverted box, or a chunk whose statistics cannot be read. A row group of nulls only constrains nothing. Geography gets no bound, as iceberg-go writes none for it (geodesic edges, boxes across the antimeridian). Tests: points and polygons far apart through a bin-packed compaction (the bound equals the union of every geometry), a sorted compaction (any bound covers every geometry), a column of nulls only, an unparseable value in a second row group (no bound; without the rule the file got the bound of the first row group only), geography, and the aggregation rules. * iceberg: do not order compaction bins by a geometry column When a table declares no usable sort order, compaction orders a bin's files by the first column every file has bounds for. A v3 table whose first column is a geometry qualifies as soon as its files carry geometry bounds (DuckDB writes them, and compacted files now do too). Those bounds decode into geo literals that compareLiterals cannot compare, so the order sorted nothing, yet an oversized bin was still split into "ordered" runs by splitOrderedBin. Geometry and geography columns are now skipped in both the declared sort order and the inferred one, so such a table is ordered by its next column with bounds. Test: TestResolveCompactionOrderSkipsGeometry, a geometry column first and an id column second; on master the order is the geometry column. --------- Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
253 lines
8.1 KiB
Go
253 lines
8.1 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"bytes"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/apache/iceberg-go"
|
|
"github.com/parquet-go/parquet-go"
|
|
"github.com/parquet-go/parquet-go/format"
|
|
)
|
|
|
|
// boundTruncateLength is the default metrics mode of the Iceberg reference
|
|
// implementation, truncate(16), applied to string and binary bounds.
|
|
const boundTruncateLength = 16
|
|
|
|
// columnStats is what a data file's manifest entry records about its columns,
|
|
// keyed by Iceberg field id. Readers plan scans from it.
|
|
type columnStats struct {
|
|
sizes, values, nulls map[int]int64
|
|
lower, upper map[int][]byte
|
|
splitOffsets []int64
|
|
}
|
|
|
|
func (s *columnStats) applyTo(b *iceberg.DataFileBuilder) {
|
|
b.ColumnSizes(s.sizes).ValueCounts(s.values).NullValueCounts(s.nulls).
|
|
LowerBoundValues(s.lower).UpperBoundValues(s.upper).SplitOffsets(s.splitOffsets)
|
|
}
|
|
|
|
// collectColumnStats reads back the footer of a Parquet file the compactor
|
|
// wrote. Counts and sizes are summed over the row groups and the bounds are
|
|
// the minimum and maximum of the row group statistics, encoded with the
|
|
// spec's single-value serialization. A column whose bounds cannot be read
|
|
// for every row group, or converted exactly, gets no bounds.
|
|
func collectColumnStats(data []byte, schema *iceberg.Schema) (*columnStats, error) {
|
|
file, err := parquet.OpenFile(bytes.NewReader(data), int64(len(data)),
|
|
parquet.SkipPageIndex(true), parquet.SkipBloomFilters(true))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var leaves []*parquet.Column
|
|
var walk func(*parquet.Column)
|
|
walk = func(c *parquet.Column) {
|
|
if c.Leaf() {
|
|
leaves = append(leaves, c)
|
|
return
|
|
}
|
|
for _, child := range c.Columns() {
|
|
walk(child)
|
|
}
|
|
}
|
|
walk(file.Root())
|
|
|
|
type bounds struct {
|
|
field iceberg.NestedField
|
|
typ parquet.Type
|
|
min, max parquet.Value
|
|
seen bool
|
|
invalid bool
|
|
}
|
|
stats := &columnStats{sizes: map[int]int64{}, values: map[int]int64{}, nulls: map[int]int64{},
|
|
lower: map[int][]byte{}, upper: map[int][]byte{}}
|
|
agg := map[int]*bounds{}
|
|
geoAgg := map[int]*geometryBounds{}
|
|
meta := file.Metadata()
|
|
for rgIdx, rg := range file.RowGroups() {
|
|
stats.splitOffsets = append(stats.splitOffsets, rowGroupOffset(&meta.RowGroups[rgIdx]))
|
|
for col, cc := range rg.ColumnChunks() {
|
|
field, ok := icebergFieldOf(leaves[col], schema)
|
|
if !ok {
|
|
continue
|
|
}
|
|
stats.sizes[field.ID] += meta.RowGroups[rgIdx].Columns[col].MetaData.TotalCompressedSize
|
|
stats.values[field.ID] += cc.NumValues()
|
|
b := agg[field.ID]
|
|
if b == nil {
|
|
b = &bounds{field: field, typ: leaves[col].Type()}
|
|
agg[field.ID] = b
|
|
}
|
|
chunk, ok := cc.(*parquet.FileColumnChunk)
|
|
if !ok {
|
|
b.invalid = true
|
|
geoAgg[field.ID] = &geometryBounds{invalid: true}
|
|
continue
|
|
}
|
|
stats.nulls[field.ID] += chunk.NullCount()
|
|
switch field.Type.(type) {
|
|
case iceberg.GeometryType:
|
|
// the bound comes from the row group's geospatial statistics
|
|
g := geoAgg[field.ID]
|
|
if g == nil {
|
|
g = &geometryBounds{}
|
|
geoAgg[field.ID] = g
|
|
}
|
|
g.add(meta.RowGroups[rgIdx].Columns[col].MetaData.GeospatialStatistics, chunk.NumValues(), chunk.NullCount())
|
|
continue
|
|
case iceberg.GeographyType:
|
|
// no bound: edges are geodesic and a box may cross the antimeridian
|
|
continue
|
|
}
|
|
if chunk.NullCount() == chunk.NumValues() {
|
|
continue // an all-null chunk constrains nothing
|
|
}
|
|
lo, hi, ok := chunk.Bounds()
|
|
switch {
|
|
case !ok:
|
|
b.invalid = true
|
|
case !b.seen:
|
|
b.min, b.max, b.seen = lo.Clone(), hi.Clone(), true
|
|
default:
|
|
if b.typ.Compare(lo, b.min) < 0 {
|
|
b.min = lo.Clone()
|
|
}
|
|
if b.typ.Compare(hi, b.max) > 0 {
|
|
b.max = hi.Clone()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for id, b := range agg {
|
|
if b.invalid || !b.seen {
|
|
continue
|
|
}
|
|
lo, okLo := boundLiteral(b.min, b.typ, b.field.Type)
|
|
hi, okHi := boundLiteral(b.max, b.typ, b.field.Type)
|
|
if !okLo || !okHi {
|
|
continue
|
|
}
|
|
lo, hi, okHi = truncateBounds(lo, hi)
|
|
if raw, err := lo.MarshalBinary(); err == nil {
|
|
stats.lower[id] = raw
|
|
}
|
|
if raw, err := hi.MarshalBinary(); err == nil && okHi {
|
|
stats.upper[id] = raw
|
|
}
|
|
}
|
|
for id, g := range geoAgg {
|
|
if lower, upper, ok := g.encoded(); ok {
|
|
stats.lower[id], stats.upper[id] = lower, upper
|
|
}
|
|
}
|
|
sort.Slice(stats.splitOffsets, func(i, j int) bool { return stats.splitOffsets[i] < stats.splitOffsets[j] })
|
|
return stats, nil
|
|
}
|
|
|
|
// icebergFieldOf finds the Iceberg field of a Parquet leaf column by the field
|
|
// id the writer stored, or by its dotted path in a file written without ids.
|
|
// Only primitive fields outside lists and maps carry statistics.
|
|
func icebergFieldOf(leaf *parquet.Column, schema *iceberg.Schema) (iceberg.NestedField, bool) {
|
|
var field iceberg.NestedField
|
|
var ok bool
|
|
if leaf.MaxRepetitionLevel() > 0 {
|
|
return field, false
|
|
}
|
|
if id := leaf.ID(); id > 0 {
|
|
field, ok = schema.FindFieldByID(id)
|
|
} else {
|
|
field, ok = schema.FindFieldByName(strings.Join(leaf.Path(), "."))
|
|
}
|
|
if !ok {
|
|
return field, false
|
|
}
|
|
_, primitive := field.Type.(iceberg.PrimitiveType)
|
|
return field, primitive
|
|
}
|
|
|
|
// rowGroupOffset is where a row group's bytes start: the split offset readers
|
|
// plan one task per row group from.
|
|
func rowGroupOffset(rg *format.RowGroup) int64 {
|
|
if rg.FileOffset > 0 || len(rg.Columns) == 0 {
|
|
return rg.FileOffset
|
|
}
|
|
first := rg.Columns[0].MetaData
|
|
if first.DictionaryPageOffset > 0 && first.DictionaryPageOffset < first.DataPageOffset {
|
|
return first.DictionaryPageOffset
|
|
}
|
|
return first.DataPageOffset
|
|
}
|
|
|
|
// boundLiteral converts a Parquet statistics value into a literal of the
|
|
// column's Iceberg type, when the conversion is exact. Other types (decimal,
|
|
// time, uuid, fixed, timestamps not in the type's unit) get no bound.
|
|
func boundLiteral(v parquet.Value, pt parquet.Type, t iceberg.Type) (iceberg.Literal, bool) {
|
|
if v.IsNull() {
|
|
return nil, false
|
|
}
|
|
switch t.(type) {
|
|
case iceberg.BooleanType:
|
|
return iceberg.BoolLiteral(v.Boolean()), v.Kind() == parquet.Boolean
|
|
case iceberg.Int32Type:
|
|
return iceberg.Int32Literal(v.Int32()), v.Kind() == parquet.Int32
|
|
case iceberg.Int64Type:
|
|
switch v.Kind() {
|
|
case parquet.Int64:
|
|
return iceberg.Int64Literal(v.Int64()), true
|
|
case parquet.Int32:
|
|
return iceberg.Int64Literal(int64(v.Int32())), true
|
|
}
|
|
case iceberg.Float32Type:
|
|
f := v.Float()
|
|
return iceberg.Float32Literal(f), v.Kind() == parquet.Float && !math.IsNaN(float64(f))
|
|
case iceberg.Float64Type:
|
|
f := v.Double()
|
|
return iceberg.Float64Literal(f), v.Kind() == parquet.Double && !math.IsNaN(f)
|
|
case iceberg.DateType:
|
|
return iceberg.DateLiteral(iceberg.Date(v.Int32())), v.Kind() == parquet.Int32
|
|
case iceberg.TimestampType, iceberg.TimestampTzType:
|
|
return iceberg.TimestampLiteral(iceberg.Timestamp(v.Int64())), v.Kind() == parquet.Int64 && timeUnit(pt) == time.Microsecond
|
|
case iceberg.TimestampNsType:
|
|
return iceberg.TimestampNsLiteral(iceberg.TimestampNano(v.Int64())), v.Kind() == parquet.Int64 && timeUnit(pt) == time.Nanosecond
|
|
case iceberg.StringType:
|
|
b := v.ByteArray()
|
|
return iceberg.StringLiteral(string(b)), v.Kind() == parquet.ByteArray && utf8.Valid(b)
|
|
case iceberg.BinaryType:
|
|
return iceberg.BinaryLiteral(bytes.Clone(v.ByteArray())), v.Kind() == parquet.ByteArray
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func timeUnit(pt parquet.Type) time.Duration {
|
|
if pt == nil || pt.LogicalType() == nil {
|
|
return 0
|
|
}
|
|
if ts, ok := pt.LogicalType().Value.(*format.TimestampType); ok && ts.Unit.Value != nil {
|
|
return ts.Unit.Value.Duration()
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// truncateBounds applies truncate(16) to string and binary bounds. A prefix is
|
|
// still a lower bound; a shortened upper bound is not, so a value longer than
|
|
// the limit keeps no upper bound (the reference implementation increments the
|
|
// last character instead).
|
|
func truncateBounds(lo, hi iceberg.Literal) (iceberg.Literal, iceberg.Literal, bool) {
|
|
switch l := lo.(type) {
|
|
case iceberg.StringLiteral:
|
|
if r := []rune(string(l)); len(r) > boundTruncateLength {
|
|
lo = iceberg.StringLiteral(string(r[:boundTruncateLength]))
|
|
}
|
|
return lo, hi, utf8.RuneCountInString(string(hi.(iceberg.StringLiteral))) <= boundTruncateLength
|
|
case iceberg.BinaryLiteral:
|
|
if len(l) > boundTruncateLength {
|
|
lo = l[:boundTruncateLength]
|
|
}
|
|
return lo, hi, len(hi.(iceberg.BinaryLiteral)) <= boundTruncateLength
|
|
}
|
|
return lo, hi, true
|
|
}
|