mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-12 17:27:50 +02:00
* 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>
202 lines
6.1 KiB
Go
202 lines
6.1 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"sort"
|
|
|
|
"github.com/apache/iceberg-go"
|
|
"github.com/apache/iceberg-go/table"
|
|
)
|
|
|
|
// compactionOrder names the column by whose bounds a bin's input files are
|
|
// ordered before they are merged: the first identity field of the table's
|
|
// sort order when it declares one, otherwise the first column every candidate
|
|
// file carries bounds for. Rows are copied file by file, so a merged file is
|
|
// as ordered as the sequence of its inputs.
|
|
type compactionOrder struct {
|
|
fieldID int
|
|
typ iceberg.PrimitiveType
|
|
descending bool
|
|
// bestEffort marks an order inferred from column bounds rather than the
|
|
// table's declared sort order; runs it cannot form may still merge by
|
|
// size since contiguous ranges are a preference, not a requirement.
|
|
bestEffort bool
|
|
}
|
|
|
|
func resolveCompactionOrder(meta table.Metadata, entries []iceberg.ManifestEntry) *compactionOrder {
|
|
if meta == nil || meta.CurrentSchema() == nil || len(entries) == 0 {
|
|
return nil
|
|
}
|
|
schema := meta.CurrentSchema()
|
|
if sortOrder := meta.SortOrder(); !sortOrder.IsUnsorted() {
|
|
for _, sortField := range sortOrder.Fields() {
|
|
if _, ok := sortField.Transform.(iceberg.IdentityTransform); !ok {
|
|
continue
|
|
}
|
|
field, ok := schema.FindFieldByID(sortField.SourceID())
|
|
if !ok {
|
|
continue
|
|
}
|
|
typ, ok := field.Type.(iceberg.PrimitiveType)
|
|
if !ok {
|
|
continue
|
|
}
|
|
candidate := &compactionOrder{fieldID: field.ID, typ: typ, descending: sortField.Direction == table.SortDESC}
|
|
// The declared order only holds when every entry carries a bound
|
|
// for it; otherwise merging would claim sortedness it cannot
|
|
// verify. Fall back to inference instead of ordering by a later
|
|
// sort field alone.
|
|
if candidate.coversEntries(entries) {
|
|
return candidate
|
|
}
|
|
break
|
|
}
|
|
}
|
|
// A column only orders the merge when every entry in the group carries a
|
|
// bound for it. The check is scoped to the entries handed in — a bin's
|
|
// eligible files — so an unrelated file without bounds (oversized, a
|
|
// different format, or another partition) cannot disable ordering here.
|
|
for _, field := range schema.Fields() {
|
|
typ, ok := field.Type.(iceberg.PrimitiveType)
|
|
if !ok {
|
|
continue
|
|
}
|
|
candidate := &compactionOrder{fieldID: field.ID, typ: typ, bestEffort: true}
|
|
if candidate.coversEntries(entries) {
|
|
return candidate
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (o *compactionOrder) coversEntries(entries []iceberg.ManifestEntry) bool {
|
|
for _, entry := range entries {
|
|
if _, ok := o.key(entry); !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// key is the bound a file is ordered by: its lower bound on the column for an
|
|
// ascending order, its upper bound for a descending one.
|
|
func (o *compactionOrder) key(entry iceberg.ManifestEntry) (iceberg.Literal, bool) {
|
|
df := entry.DataFile()
|
|
raw := df.LowerBoundValues()[o.fieldID]
|
|
if o.descending {
|
|
raw = df.UpperBoundValues()[o.fieldID]
|
|
}
|
|
if raw == nil {
|
|
return nil, false
|
|
}
|
|
lit, err := iceberg.LiteralFromBytes(o.typ, raw)
|
|
return lit, err == nil
|
|
}
|
|
|
|
// sortEntries orders a bin's files by their key. Files without a key sort
|
|
// after the rest, in their original order.
|
|
func (o *compactionOrder) sortEntries(entries []iceberg.ManifestEntry) {
|
|
if o == nil || len(entries) < 2 {
|
|
return
|
|
}
|
|
type keyed struct {
|
|
key iceberg.Literal
|
|
ok bool
|
|
}
|
|
keys := make([]keyed, len(entries))
|
|
idx := make([]int, len(entries))
|
|
for i, entry := range entries {
|
|
k, ok := o.key(entry)
|
|
keys[i], idx[i] = keyed{k, ok}, i
|
|
}
|
|
sort.SliceStable(idx, func(i, j int) bool {
|
|
a, b := keys[idx[i]], keys[idx[j]]
|
|
if a.ok != b.ok {
|
|
return a.ok
|
|
}
|
|
if !a.ok {
|
|
return false
|
|
}
|
|
c, ok := compareLiterals(a.key, b.key)
|
|
if o.descending {
|
|
c = -c
|
|
}
|
|
return ok && c < 0
|
|
})
|
|
sorted := make([]iceberg.ManifestEntry, len(entries))
|
|
for i, j := range idx {
|
|
sorted[i] = entries[j]
|
|
}
|
|
copy(entries, sorted)
|
|
}
|
|
|
|
// splitOrderedBin splits a bin whose files are in bound order into runs of
|
|
// consecutive files that stay under targetSize, so every output covers one
|
|
// contiguous range of the ordering column. The returned entries are the runs
|
|
// too short to reach minFiles: the caller can still merge them by size when
|
|
// contiguous ranges are only a preference, or leave them for a later pass
|
|
// when order must hold.
|
|
func splitOrderedBin(bin compactionBin, targetSize int64, minFiles int) (valid []compactionBin, leftover []iceberg.ManifestEntry) {
|
|
newBin := func() compactionBin {
|
|
return compactionBin{PartitionKey: bin.PartitionKey, Partition: bin.Partition, SpecID: bin.SpecID}
|
|
}
|
|
current := newBin()
|
|
flush := func() {
|
|
if len(current.Entries) >= minFiles {
|
|
valid = append(valid, current)
|
|
} else {
|
|
leftover = append(leftover, current.Entries...)
|
|
}
|
|
current = newBin()
|
|
}
|
|
for _, entry := range bin.Entries {
|
|
size := entry.DataFile().FileSizeBytes()
|
|
if current.TotalSize > 0 && current.TotalSize+size > targetSize {
|
|
flush()
|
|
}
|
|
current.Entries = append(current.Entries, entry)
|
|
current.TotalSize += size
|
|
}
|
|
flush()
|
|
return valid, leftover
|
|
}
|
|
|
|
// compareLiterals orders two literals of the same type; false when they are
|
|
// not comparable.
|
|
func compareLiterals(a, b iceberg.Literal) (int, bool) {
|
|
switch x := a.(type) {
|
|
case iceberg.TypedLiteral[bool]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[int32]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[int64]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[float32]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[float64]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[iceberg.Date]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[iceberg.Time]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[iceberg.Timestamp]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[iceberg.TimestampNano]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[string]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[[]byte]:
|
|
return compareTyped(x, b)
|
|
case iceberg.TypedLiteral[iceberg.Decimal]:
|
|
return compareTyped(x, b)
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func compareTyped[T iceberg.LiteralType](a iceberg.TypedLiteral[T], b iceberg.Literal) (int, bool) {
|
|
bt, ok := b.(iceberg.TypedLiteral[T])
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return a.Comparator()(a.Value(), bt.Value()), true
|
|
}
|