mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-13 01:28:37 +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>
376 lines
13 KiB
Go
376 lines
13 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
|
)
|
|
|
|
const (
|
|
jobType = "iceberg_maintenance"
|
|
|
|
defaultSnapshotRetentionHours = 168 // 7 days
|
|
defaultMaxSnapshotsToKeep = 5
|
|
defaultOrphanOlderThanHours = 72
|
|
defaultMaxCommitRetries = 5
|
|
defaultTargetFileSizeMB = 256
|
|
defaultMinInputFiles = 5
|
|
defaultDeleteTargetFileSizeMB = 64
|
|
defaultDeleteMinInputFiles = 2
|
|
defaultDeleteMaxGroupSizeMB = 256
|
|
defaultDeleteMaxOutputFiles = 8
|
|
defaultRewriteStrategy = "binpack"
|
|
rewriteStrategyAuto = "auto"
|
|
defaultSortBufferRows = 262144
|
|
minSortBufferRows = 1024
|
|
defaultMinManifestsToRewrite = 5
|
|
minManifestsToRewrite = 2
|
|
defaultOperations = "all"
|
|
|
|
// Metric keys returned by maintenance operations.
|
|
MetricFilesMerged = "files_merged"
|
|
MetricFilesWritten = "files_written"
|
|
MetricBins = "bins"
|
|
MetricSnapshotsExpired = "snapshots_expired"
|
|
MetricFilesDeleted = "files_deleted"
|
|
MetricOrphansRemoved = "orphans_removed"
|
|
MetricManifestsRewritten = "manifests_rewritten"
|
|
MetricDeleteFilesRewritten = "delete_files_rewritten"
|
|
MetricDeleteFilesWritten = "delete_files_written"
|
|
MetricDeleteBytesRewritten = "delete_bytes_rewritten"
|
|
MetricDeleteGroupsPlanned = "delete_groups_planned"
|
|
MetricDeleteGroupsSkipped = "delete_groups_skipped"
|
|
MetricEntriesTotal = "entries_total"
|
|
MetricDurationMs = "duration_ms"
|
|
)
|
|
|
|
const bytesPerMB int64 = 1024 * 1024
|
|
|
|
const msPerHour int64 = 3600 * 1000
|
|
|
|
// maxOrphanOlderThanHours is the largest cutoff that still survives conversion
|
|
// to a time.Duration. Beyond it the multiplication wraps negative, putting the
|
|
// cutoff in the future and making every file look like an orphan.
|
|
const maxOrphanOlderThanHours = int64(math.MaxInt64 / int64(time.Hour))
|
|
|
|
// mbToBytes converts megabytes to bytes, saturating instead of overflowing.
|
|
func mbToBytes(mb int64) int64 {
|
|
if mb <= 0 {
|
|
return 0
|
|
}
|
|
if mb > math.MaxInt64/bytesPerMB {
|
|
return math.MaxInt64
|
|
}
|
|
return mb * bytesPerMB
|
|
}
|
|
|
|
// addDays sums two day counts, saturating instead of overflowing. Negative
|
|
// inputs are treated as unset.
|
|
func addDays(a, b int64) int64 {
|
|
if a < 0 {
|
|
a = 0
|
|
}
|
|
if b < 0 {
|
|
b = 0
|
|
}
|
|
if a > math.MaxInt64-b {
|
|
return math.MaxInt64
|
|
}
|
|
return a + b
|
|
}
|
|
|
|
// daysToHours converts days to hours, saturating instead of overflowing.
|
|
// applyThresholdDefaults clamps the result down to a usable cutoff.
|
|
func daysToHours(days int64) int64 {
|
|
if days <= 0 {
|
|
return 0
|
|
}
|
|
if days > math.MaxInt64/24 {
|
|
return math.MaxInt64
|
|
}
|
|
return days * 24
|
|
}
|
|
|
|
// hoursToMs converts hours to milliseconds, saturating instead of overflowing.
|
|
func hoursToMs(hours int64) int64 {
|
|
if hours <= 0 {
|
|
return 0
|
|
}
|
|
if hours > math.MaxInt64/msPerHour {
|
|
return math.MaxInt64
|
|
}
|
|
return hours * msPerHour
|
|
}
|
|
|
|
// Config holds parsed worker config values.
|
|
type Config struct {
|
|
// Milliseconds rather than hours so sub-hour retentions survive; the
|
|
// plugin config key stays in hours.
|
|
SnapshotRetentionMs int64
|
|
MaxSnapshotsToKeep int64
|
|
OrphanOlderThanHours int64
|
|
MaxCommitRetries int64
|
|
TargetFileSizeBytes int64
|
|
MinInputFiles int64
|
|
DeleteTargetFileSizeBytes int64
|
|
DeleteMinInputFiles int64
|
|
DeleteMaxFileGroupSizeBytes int64
|
|
DeleteMaxOutputFiles int64
|
|
MinManifestsToRewrite int64
|
|
Operations string
|
|
TablePropertiesOverride bool
|
|
ApplyDeletes bool
|
|
Where string
|
|
RewriteStrategy string
|
|
SortMaxInputBytes int64
|
|
SortBufferRows int64
|
|
SortSpillDir string
|
|
// Row groups of a compacted file, from the table's write.parquet.*
|
|
// properties; zero means the default.
|
|
RowGroupSizeBytes int64
|
|
RowGroupRowLimit int64
|
|
}
|
|
|
|
// ParseConfig extracts an iceberg maintenance Config from plugin config values.
|
|
// Values are clamped to safe minimums to prevent misconfiguration.
|
|
func ParseConfig(values map[string]*plugin_pb.ConfigValue) Config {
|
|
cfg := Config{
|
|
SnapshotRetentionMs: hoursToMs(readInt64Config(values, "snapshot_retention_hours", defaultSnapshotRetentionHours)),
|
|
MaxSnapshotsToKeep: readInt64Config(values, "max_snapshots_to_keep", defaultMaxSnapshotsToKeep),
|
|
OrphanOlderThanHours: readInt64Config(values, "orphan_older_than_hours", defaultOrphanOlderThanHours),
|
|
MaxCommitRetries: readInt64Config(values, "max_commit_retries", defaultMaxCommitRetries),
|
|
TargetFileSizeBytes: readSizeMBConfig(values, "target_file_size_mb", defaultTargetFileSizeMB),
|
|
MinInputFiles: readInt64Config(values, "min_input_files", defaultMinInputFiles),
|
|
DeleteTargetFileSizeBytes: readSizeMBConfig(values, "delete_target_file_size_mb", defaultDeleteTargetFileSizeMB),
|
|
DeleteMinInputFiles: readInt64Config(values, "delete_min_input_files", defaultDeleteMinInputFiles),
|
|
DeleteMaxFileGroupSizeBytes: readSizeMBConfig(values, "delete_max_file_group_size_mb", defaultDeleteMaxGroupSizeMB),
|
|
DeleteMaxOutputFiles: readInt64Config(values, "delete_max_output_files", defaultDeleteMaxOutputFiles),
|
|
MinManifestsToRewrite: readInt64Config(values, "min_manifests_to_rewrite", defaultMinManifestsToRewrite),
|
|
Operations: readStringConfig(values, "operations", defaultOperations),
|
|
TablePropertiesOverride: readBoolConfig(values, "table_properties_override", true),
|
|
ApplyDeletes: readBoolConfig(values, "apply_deletes", true),
|
|
Where: strings.TrimSpace(readStringConfig(values, "where", "")),
|
|
RewriteStrategy: strings.TrimSpace(strings.ToLower(readStringConfig(values, "rewrite_strategy", defaultRewriteStrategy))),
|
|
SortMaxInputBytes: readSizeMBConfig(values, "sort_max_input_mb", 0),
|
|
SortBufferRows: readInt64Config(values, "sort_buffer_rows", defaultSortBufferRows),
|
|
SortSpillDir: strings.TrimSpace(readStringConfig(values, "sort_spill_dir", "")),
|
|
}
|
|
|
|
// Clamp the fields that are always defaulted by worker config parsing.
|
|
if cfg.SnapshotRetentionMs <= 0 {
|
|
cfg.SnapshotRetentionMs = hoursToMs(defaultSnapshotRetentionHours)
|
|
}
|
|
if cfg.MaxSnapshotsToKeep <= 0 {
|
|
cfg.MaxSnapshotsToKeep = defaultMaxSnapshotsToKeep
|
|
}
|
|
if cfg.MaxCommitRetries <= 0 {
|
|
cfg.MaxCommitRetries = defaultMaxCommitRetries
|
|
}
|
|
cfg = applyThresholdDefaults(cfg)
|
|
return cfg
|
|
}
|
|
|
|
func applyThresholdDefaults(cfg Config) Config {
|
|
if cfg.OrphanOlderThanHours <= 0 {
|
|
cfg.OrphanOlderThanHours = defaultOrphanOlderThanHours
|
|
}
|
|
if cfg.OrphanOlderThanHours > maxOrphanOlderThanHours {
|
|
cfg.OrphanOlderThanHours = maxOrphanOlderThanHours
|
|
}
|
|
if cfg.TargetFileSizeBytes <= 0 {
|
|
cfg.TargetFileSizeBytes = defaultTargetFileSizeMB * 1024 * 1024
|
|
}
|
|
if cfg.MinInputFiles < 2 {
|
|
cfg.MinInputFiles = defaultMinInputFiles
|
|
}
|
|
if cfg.DeleteTargetFileSizeBytes <= 0 {
|
|
cfg.DeleteTargetFileSizeBytes = defaultDeleteTargetFileSizeMB * 1024 * 1024
|
|
}
|
|
if cfg.DeleteMinInputFiles < 2 {
|
|
cfg.DeleteMinInputFiles = defaultDeleteMinInputFiles
|
|
}
|
|
if cfg.DeleteMaxFileGroupSizeBytes <= 0 {
|
|
cfg.DeleteMaxFileGroupSizeBytes = defaultDeleteMaxGroupSizeMB * 1024 * 1024
|
|
}
|
|
if cfg.DeleteMaxOutputFiles <= 0 {
|
|
cfg.DeleteMaxOutputFiles = defaultDeleteMaxOutputFiles
|
|
}
|
|
if cfg.RewriteStrategy == "" {
|
|
cfg.RewriteStrategy = defaultRewriteStrategy
|
|
}
|
|
if cfg.RewriteStrategy != "binpack" && cfg.RewriteStrategy != "sort" && cfg.RewriteStrategy != rewriteStrategyAuto {
|
|
cfg.RewriteStrategy = defaultRewriteStrategy
|
|
}
|
|
if cfg.SortMaxInputBytes < 0 {
|
|
cfg.SortMaxInputBytes = 0
|
|
}
|
|
// A run smaller than this buys no memory back worth the extra runs, and
|
|
// the writer stops at 32K of them, so a tiny value would cap the output.
|
|
if cfg.SortBufferRows < minSortBufferRows {
|
|
cfg.SortBufferRows = defaultSortBufferRows
|
|
}
|
|
if cfg.MinManifestsToRewrite < minManifestsToRewrite {
|
|
cfg.MinManifestsToRewrite = minManifestsToRewrite
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func readSizeMBConfig(values map[string]*plugin_pb.ConfigValue, field string, fallbackMB int64) int64 {
|
|
mb := readInt64Config(values, field, fallbackMB)
|
|
if mb <= 0 {
|
|
return 0
|
|
}
|
|
maxMB := int64(^uint64(0)>>1) / bytesPerMB
|
|
if mb > maxMB {
|
|
glog.V(1).Infof("readSizeMBConfig: clamping %q from %d MB to %d MB", field, mb, maxMB)
|
|
mb = maxMB
|
|
}
|
|
return mb * bytesPerMB
|
|
}
|
|
|
|
// parseOperations returns the ordered list of maintenance operations to execute.
|
|
// Order follows Iceberg best practices: compact → rewrite_position_delete_files
|
|
// → expire_snapshots → remove_orphans → rewrite_manifests.
|
|
// Returns an error if any unknown operation is specified or the result would be empty.
|
|
func parseOperations(ops string) ([]string, error) {
|
|
ops = strings.TrimSpace(strings.ToLower(ops))
|
|
if ops == "" || ops == "all" {
|
|
return []string{"compact", "rewrite_position_delete_files", "expire_snapshots", "remove_orphans", "rewrite_manifests"}, nil
|
|
}
|
|
|
|
validOps := map[string]struct{}{
|
|
"compact": {},
|
|
"rewrite_position_delete_files": {},
|
|
"expire_snapshots": {},
|
|
"remove_orphans": {},
|
|
"rewrite_manifests": {},
|
|
}
|
|
|
|
requested := make(map[string]struct{})
|
|
for _, op := range strings.Split(ops, ",") {
|
|
op = strings.TrimSpace(op)
|
|
if op == "" {
|
|
continue
|
|
}
|
|
if _, ok := validOps[op]; !ok {
|
|
return nil, fmt.Errorf("unknown maintenance operation %q (valid: compact, rewrite_position_delete_files, expire_snapshots, remove_orphans, rewrite_manifests)", op)
|
|
}
|
|
requested[op] = struct{}{}
|
|
}
|
|
|
|
// Return in canonical order: compact → rewrite_position_delete_files →
|
|
// expire_snapshots → remove_orphans → rewrite_manifests
|
|
canonicalOrder := []string{"compact", "rewrite_position_delete_files", "expire_snapshots", "remove_orphans", "rewrite_manifests"}
|
|
var result []string
|
|
for _, op := range canonicalOrder {
|
|
if _, ok := requested[op]; ok {
|
|
result = append(result, op)
|
|
}
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
return nil, fmt.Errorf("no valid maintenance operations specified")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func extractMetadataVersion(metadataFileName string) int {
|
|
// Parse "v3.metadata.json" or "v3-{nonce}.metadata.json" → 3
|
|
name := strings.TrimPrefix(metadataFileName, "v")
|
|
name = strings.TrimSuffix(name, ".metadata.json")
|
|
// Strip any nonce suffix (e.g. "3-1709766000" → "3")
|
|
if dashIdx := strings.Index(name, "-"); dashIdx > 0 {
|
|
name = name[:dashIdx]
|
|
}
|
|
version, _ := strconv.Atoi(name)
|
|
return version
|
|
}
|
|
|
|
// readStringConfig reads a string value from plugin config, with fallback.
|
|
func readStringConfig(values map[string]*plugin_pb.ConfigValue, field string, fallback string) string {
|
|
if values == nil {
|
|
return fallback
|
|
}
|
|
value := values[field]
|
|
if value == nil {
|
|
return fallback
|
|
}
|
|
switch kind := value.Kind.(type) {
|
|
case *plugin_pb.ConfigValue_StringValue:
|
|
return kind.StringValue
|
|
case *plugin_pb.ConfigValue_Int64Value:
|
|
return strconv.FormatInt(kind.Int64Value, 10)
|
|
case *plugin_pb.ConfigValue_DoubleValue:
|
|
return strconv.FormatFloat(kind.DoubleValue, 'f', -1, 64)
|
|
case *plugin_pb.ConfigValue_BoolValue:
|
|
return strconv.FormatBool(kind.BoolValue)
|
|
default:
|
|
glog.V(1).Infof("readStringConfig: unexpected config value type %T for field %q, using fallback", value.Kind, field)
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// readBoolConfig reads a bool value from plugin config, with fallback.
|
|
func readBoolConfig(values map[string]*plugin_pb.ConfigValue, field string, fallback bool) bool {
|
|
if values == nil {
|
|
return fallback
|
|
}
|
|
value := values[field]
|
|
if value == nil {
|
|
return fallback
|
|
}
|
|
switch kind := value.Kind.(type) {
|
|
case *plugin_pb.ConfigValue_BoolValue:
|
|
return kind.BoolValue
|
|
case *plugin_pb.ConfigValue_StringValue:
|
|
s := strings.TrimSpace(strings.ToLower(kind.StringValue))
|
|
if s == "true" || s == "1" || s == "yes" {
|
|
return true
|
|
}
|
|
if s == "false" || s == "0" || s == "no" {
|
|
return false
|
|
}
|
|
glog.V(1).Infof("readBoolConfig: unrecognized string value %q for field %q, using fallback %v", kind.StringValue, field, fallback)
|
|
case *plugin_pb.ConfigValue_Int64Value:
|
|
return kind.Int64Value != 0
|
|
default:
|
|
glog.V(1).Infof("readBoolConfig: unexpected config value type %T for field %q, using fallback", value.Kind, field)
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// readInt64Config reads an int64 value from plugin config, with fallback.
|
|
func readInt64Config(values map[string]*plugin_pb.ConfigValue, field string, fallback int64) int64 {
|
|
if values == nil {
|
|
return fallback
|
|
}
|
|
value := values[field]
|
|
if value == nil {
|
|
return fallback
|
|
}
|
|
switch kind := value.Kind.(type) {
|
|
case *plugin_pb.ConfigValue_Int64Value:
|
|
return kind.Int64Value
|
|
case *plugin_pb.ConfigValue_DoubleValue:
|
|
return int64(kind.DoubleValue)
|
|
case *plugin_pb.ConfigValue_StringValue:
|
|
parsed, err := strconv.ParseInt(strings.TrimSpace(kind.StringValue), 10, 64)
|
|
if err == nil {
|
|
return parsed
|
|
}
|
|
case *plugin_pb.ConfigValue_BoolValue:
|
|
if kind.BoolValue {
|
|
return 1
|
|
}
|
|
return 0
|
|
default:
|
|
glog.V(1).Infof("readInt64Config: unexpected config value type %T for field %q, using fallback", value.Kind, field)
|
|
}
|
|
return fallback
|
|
}
|