Files
seaweedfs/weed/worker/tasks/iceberg/compact_stats.go
T
Chris LuandDevin 0305e837fd iceberg maintenance: keep compacted files prunable (stats, bound order, row groups) (#11654)
* iceberg maintenance: record column statistics on compacted files

A compacted file's manifest entry was built with no column_sizes,
value_counts, null_value_counts, lower_bounds, upper_bounds or
split_offsets, so no reader could skip a compacted file on any
predicate. parquet-go already writes exact per-chunk min/max and null
counts into the footer; read that footer back after the merge and
record it on the data file, bounds as the spec's single-value
serialization with string and binary truncated as truncate(16). A
column whose bounds cannot be converted exactly gets none, and a
statistics failure is logged while the compaction commits anyway:
metrics are an optimization, not a correctness requirement.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* iceberg maintenance: merge bins in bound order so compacted files stay prunable

A bin's files were concatenated in manifest order, or largest-first
when a partition was split under the target size, so inputs disjoint
on a column came out as outputs that overlapped on it. Order each
bin's files by their bounds on one column before merging and split an
oversized partition into runs of consecutive files, so every output
covers one contiguous range. The column is the first identity field
of the table's sort order when it declares one (a descending order
sorts by upper bound), otherwise the first schema column every
candidate file has bounds for; detection resolves the same order so
it plans the bins execution builds. Files without bounds keep the old
behavior.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* iceberg maintenance: cap compacted files' row groups from table config

Neither merge writer set a row-group limit (parquet-go's default is
unlimited rows), so every compacted file was a single row group and
readers could not skip inside it either. Rows per row group now come
from the table's write.parquet.row-group-limit and
write.parquet.row-group-size-bytes, defaulting to PyIceberg's
1 048 576 rows and Iceberg's 128 MiB, with the byte size turned into
rows from the bin's inputs' compressed bytes per row and a floor of
1 024 rows. Both writers take the cap, and the statistics the entry
records list one split offset per row group.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* iceberg: keep compaction order eligibility per group, rescue stranded runs

The merge order resolved over all candidates, so one oversized or
non-Parquet file without bounds disabled ordering for files that could
participate. Resolve it per partition group over the eligible entries.

Ordered runs too short to merge were dropped entirely. Runs from an
inferred bounds order now fall back to size-based packing — ordering is
a preference there — while runs under a declared sort order are still
left for later passes so the sort contract holds.

An explicit write.parquet.row-group-limit is a cap, not a floor: values
below the estimate floor are now honored instead of being raised.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* iceberg: only use the declared sort order when its bounds are complete

An entry without bounds on the sort column sorted to the tail and merged
into an output claiming an order it cannot verify. Fall back to
bound inference instead of ordering by a later sort field alone.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* iceberg maintenance: keep ordered runs when the full repack yields nothing

The bestEffort leftover fallback removed the ordered runs before checking
whether repacking the whole bin produced any bins, discarding valid
compaction work.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:06:44 +08:00

232 lines
7.5 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{}
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
continue
}
stats.nulls[field.ID] += chunk.NullCount()
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
}
}
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
}