snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 03:23:42 +05:00
commit 89f5be005d
3997 changed files with 961970 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,371 @@
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
}
// 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
}

View File

@@ -0,0 +1,607 @@
package iceberg
import (
"bytes"
"context"
"fmt"
"math"
"path"
"sort"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/parquet-go/parquet-go"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
type deleteRewriteInput struct {
Entry iceberg.ManifestEntry
ReferencedPath string
Positions []int64
}
type deleteRewriteGroup struct {
SpecID int32
Partition map[int]any
PartitionKey string
ReferencedPath string
Inputs []deleteRewriteInput
TotalSize int64
}
type positionDeleteRow struct {
FilePath string `parquet:"file_path"`
Pos int64 `parquet:"pos"`
}
func hasEligibleDeleteRewrite(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, dataPath string,
manifests []iceberg.ManifestFile,
config Config,
meta table.Metadata,
predicate *partitionPredicate,
) (bool, error) {
groups, _, err := collectDeleteRewriteGroups(ctx, filerClient, bucketName, dataPath, manifests, specByID(meta), meta.CurrentSchema())
if err != nil {
return false, err
}
for _, group := range groups {
if predicate != nil {
spec, ok := specByID(meta)[int(group.SpecID)]
if !ok {
continue
}
match, err := predicate.Matches(spec, group.Partition)
if err != nil {
return false, err
}
if !match {
continue
}
}
if groupEligibleForRewrite(group, config) {
return true, nil
}
}
return false, nil
}
func collectDeleteRewriteGroups(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, dataPath string,
manifests []iceberg.ManifestFile,
specs map[int]iceberg.PartitionSpec,
schema *iceberg.Schema,
) (map[string]*deleteRewriteGroup, []iceberg.ManifestEntry, error) {
groups := make(map[string]*deleteRewriteGroup)
var allPositionEntries []iceberg.ManifestEntry
for _, mf := range manifests {
if mf.ManifestContent() != iceberg.ManifestContentDeletes {
continue
}
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, mf.FilePath())
if err != nil {
return nil, nil, fmt.Errorf("read delete manifest %s: %w", mf.FilePath(), err)
}
entries, err := s3tables.ReadManifest(mf, manifestData, true, specs, schema)
if err != nil {
return nil, nil, fmt.Errorf("parse delete manifest %s: %w", mf.FilePath(), err)
}
for _, entry := range entries {
if entry.DataFile().ContentType() != iceberg.EntryContentPosDeletes {
continue
}
allPositionEntries = append(allPositionEntries, entry)
fileDeletes, err := readPositionDeleteFile(ctx, filerClient, bucketName, dataPath, entry.DataFile().FilePath())
if err != nil {
return nil, nil, fmt.Errorf("read position delete file %s: %w", entry.DataFile().FilePath(), err)
}
if len(fileDeletes) != 1 {
// Phase 1 only rewrites files that target a single data file.
continue
}
var referencedPath string
var positions []int64
for fp, pos := range fileDeletes {
normalized, err := normalizeIcebergPath(fp, bucketName, dataPath)
if err != nil {
return nil, nil, fmt.Errorf("resolve deleted file %s: %w", fp, err)
}
// The rewritten delete file has to name the data file the way the
// table does, so keep the absolute form readers compare against.
referencedPath = absoluteIcebergPath(bucketName, normalized)
positions = append(positions, pos...)
}
sort.Slice(positions, func(i, j int) bool { return positions[i] < positions[j] })
partKey := partitionKey(entry.DataFile().Partition())
groupKey := fmt.Sprintf("spec%d\x00%s\x00%s", entry.DataFile().SpecID(), partKey, referencedPath)
group, ok := groups[groupKey]
if !ok {
group = &deleteRewriteGroup{
SpecID: entry.DataFile().SpecID(),
Partition: entry.DataFile().Partition(),
PartitionKey: partKey,
ReferencedPath: referencedPath,
}
groups[groupKey] = group
}
group.Inputs = append(group.Inputs, deleteRewriteInput{
Entry: entry,
ReferencedPath: referencedPath,
Positions: positions,
})
group.TotalSize += entry.DataFile().FileSizeBytes()
}
}
return groups, allPositionEntries, nil
}
func groupEligibleForRewrite(group *deleteRewriteGroup, config Config) bool {
if group == nil {
return false
}
if len(group.Inputs) < 2 {
return false
}
if group.TotalSize > config.DeleteMaxFileGroupSizeBytes {
return false
}
target := config.DeleteTargetFileSizeBytes
if target <= 0 {
target = defaultDeleteTargetFileSizeMB * 1024 * 1024
}
outputFiles := int64(estimatedDeleteOutputFiles(group.TotalSize, target))
if config.DeleteMaxOutputFiles > 0 && outputFiles > config.DeleteMaxOutputFiles {
return false
}
return int64(len(group.Inputs)) >= config.DeleteMinInputFiles
}
func estimatedDeleteOutputFiles(totalSize, targetSize int64) int {
if totalSize <= 0 || targetSize <= 0 {
return 1
}
count := int(math.Ceil(float64(totalSize) / float64(targetSize)))
if count < 1 {
return 1
}
return count
}
func manifestEntrySeqNum(entry iceberg.ManifestEntry) *int64 {
seqNum := entry.SequenceNum()
if seqNum < 0 {
return nil
}
return &seqNum
}
func manifestEntryFileSeqNum(entry iceberg.ManifestEntry) *int64 {
if fileSeqNum := entry.FileSequenceNum(); fileSeqNum != nil {
value := *fileSeqNum
return &value
}
return manifestEntrySeqNum(entry)
}
func writeManifestWithContent(
filename string,
version int,
spec iceberg.PartitionSpec,
schema *iceberg.Schema,
snapshotID int64,
entries []iceberg.ManifestEntry,
content iceberg.ManifestContent,
) (iceberg.ManifestFile, []byte, error) {
var manifestBuf bytes.Buffer
mf, err := iceberg.WriteManifest(filename, &manifestBuf, version, spec, schema, snapshotID, entries)
if err != nil {
return nil, nil, err
}
manifestBytes := manifestBuf.Bytes()
if content == iceberg.ManifestContentDeletes {
manifestBytes, err = patchManifestContentBytesToDeletes(manifestBytes)
if err != nil {
return nil, nil, err
}
}
rebuilt := iceberg.NewManifestFile(version, filename, int64(len(manifestBytes)), int32(spec.ID()), snapshotID).
Content(content).
AddedFiles(mf.AddedDataFiles()).
ExistingFiles(mf.ExistingDataFiles()).
DeletedFiles(mf.DeletedDataFiles()).
AddedRows(mf.AddedRows()).
ExistingRows(mf.ExistingRows()).
DeletedRows(mf.DeletedRows()).
Partitions(mf.Partitions()).
Build()
return rebuilt, manifestBytes, nil
}
func patchManifestContentBytesToDeletes(manifestBytes []byte) ([]byte, error) {
old := append([]byte{0x0e}, []byte("content")...)
old = append(old, 0x08)
old = append(old, []byte("data")...)
new := append([]byte{0x0e}, []byte("content")...)
new = append(new, 0x0e)
new = append(new, []byte("deletes")...)
result := bytes.Replace(manifestBytes, old, new, 1)
if bytes.Equal(result, manifestBytes) {
return nil, fmt.Errorf("delete manifest content patch failed")
}
return result, nil
}
func writePositionDeleteFile(rows []positionDeleteRow) ([]byte, error) {
var buf bytes.Buffer
writer := parquet.NewWriter(&buf, parquet.SchemaOf(new(positionDeleteRow)))
for _, row := range rows {
if err := writer.Write(&row); err != nil {
return nil, fmt.Errorf("write position delete row: %w", err)
}
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("close position delete file: %w", err)
}
return buf.Bytes(), nil
}
func (h *Handler) rewritePositionDeleteFiles(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, tablePath string,
config Config,
) (string, map[string]int64, error) {
start := time.Now()
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
if err != nil {
return "", nil, fmt.Errorf("load metadata: %w", err)
}
meta, dataPath := state.Metadata, state.DataPath
currentSnap := meta.CurrentSnapshot()
if currentSnap == nil || currentSnap.ManifestList == "" {
return "no current snapshot", nil, nil
}
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, currentSnap.ManifestList)
if err != nil {
return "", nil, fmt.Errorf("read manifest list: %w", err)
}
manifests, err := s3tables.ReadManifestList(manifestListData)
if err != nil {
return "", nil, fmt.Errorf("parse manifest list: %w", err)
}
specs := specByID(meta)
schema := meta.CurrentSchema()
var dataManifests []iceberg.ManifestFile
var allEqualityEntries []iceberg.ManifestEntry
for _, mf := range manifests {
switch mf.ManifestContent() {
case iceberg.ManifestContentData:
dataManifests = append(dataManifests, mf)
case iceberg.ManifestContentDeletes:
manifestData, readErr := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, mf.FilePath())
if readErr != nil {
return "", nil, fmt.Errorf("read delete manifest %s: %w", mf.FilePath(), readErr)
}
entries, parseErr := s3tables.ReadManifest(mf, manifestData, true, specs, schema)
if parseErr != nil {
return "", nil, fmt.Errorf("parse delete manifest %s: %w", mf.FilePath(), parseErr)
}
for _, entry := range entries {
if entry.DataFile().ContentType() == iceberg.EntryContentEqDeletes {
allEqualityEntries = append(allEqualityEntries, entry)
}
}
}
}
groupMap, allPositionEntries, err := collectDeleteRewriteGroups(ctx, filerClient, bucketName, dataPath, manifests, specs, schema)
if err != nil {
return "", nil, err
}
if len(groupMap) == 0 {
return "no position delete files eligible for rewrite", nil, nil
}
type artifact struct {
dir, fileName string
}
var writtenArtifacts []artifact
committed := false
defer func() {
if committed || len(writtenArtifacts) == 0 {
return
}
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, a := range writtenArtifacts {
if err := deleteFilerFile(cleanupCtx, filerClient, a.dir, a.fileName); err != nil {
glog.Warningf("iceberg delete rewrite: failed to clean up artifact %s/%s: %v", a.dir, a.fileName, err)
}
}
}()
specByID := specByID(meta)
predicate, err := parsePartitionPredicate(config.Where, meta)
if err != nil {
return "", nil, err
}
type specEntries struct {
specID int32
entries []iceberg.ManifestEntry
}
specEntriesMap := make(map[int32]*specEntries)
addToSpec := func(specID int32, entry iceberg.ManifestEntry) {
se, ok := specEntriesMap[specID]
if !ok {
se = &specEntries{specID: specID}
specEntriesMap[specID] = se
}
se.entries = append(se.entries, entry)
}
newSnapID := time.Now().UnixMilli()
version := meta.Version()
snapshotID := currentSnap.SnapshotID
seqNum := currentSnap.SequenceNumber + 1
metaDir := path.Join(s3tables.TablesPath, bucketName, dataPath, "metadata")
dataDir := path.Join(s3tables.TablesPath, bucketName, dataPath, "data")
artifactSuffix := compactRandomSuffix()
replacedPaths := make(map[string]struct{})
var summary snapshotSummary
var rewrittenGroups int64
var skippedGroups int64
var deleteFilesRewritten int64
var deleteFilesWritten int64
var deleteBytesRewritten int64
sortedKeys := make([]string, 0, len(groupMap))
for key := range groupMap {
sortedKeys = append(sortedKeys, key)
}
sort.Strings(sortedKeys)
for _, key := range sortedKeys {
group := groupMap[key]
if predicate != nil {
spec, ok := specByID[int(group.SpecID)]
if !ok {
continue
}
match, err := predicate.Matches(spec, group.Partition)
if err != nil {
return "", nil, err
}
if !match {
skippedGroups++
continue
}
}
if !groupEligibleForRewrite(group, config) {
skippedGroups++
continue
}
rows := make([]positionDeleteRow, 0)
for _, input := range group.Inputs {
for _, pos := range input.Positions {
rows = append(rows, positionDeleteRow{FilePath: input.ReferencedPath, Pos: pos})
}
replacedPaths[input.Entry.DataFile().FilePath()] = struct{}{}
deleteFilesRewritten++
deleteBytesRewritten += input.Entry.DataFile().FileSizeBytes()
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].FilePath != rows[j].FilePath {
return rows[i].FilePath < rows[j].FilePath
}
return rows[i].Pos < rows[j].Pos
})
outputFiles := estimatedDeleteOutputFiles(group.TotalSize, config.DeleteTargetFileSizeBytes)
rowsPerFile := (len(rows) + outputFiles - 1) / outputFiles
if rowsPerFile < 1 {
rowsPerFile = len(rows)
}
for startIdx, fileIdx := 0, 0; startIdx < len(rows); startIdx, fileIdx = startIdx+rowsPerFile, fileIdx+1 {
endIdx := startIdx + rowsPerFile
if endIdx > len(rows) {
endIdx = len(rows)
}
outputRows := rows[startIdx:endIdx]
deleteBytes, err := writePositionDeleteFile(outputRows)
if err != nil {
return "", nil, err
}
fileName := fmt.Sprintf("rewrite-delete-%d-%s-%d.parquet", newSnapID, artifactSuffix, deleteFilesWritten)
if err := ensureFilerDir(ctx, filerClient, dataDir); err != nil {
return "", nil, fmt.Errorf("ensure data dir: %w", err)
}
if err := saveFilerFile(ctx, filerClient, dataDir, fileName, deleteBytes); err != nil {
return "", nil, fmt.Errorf("save rewritten delete file: %w", err)
}
writtenArtifacts = append(writtenArtifacts, artifact{dir: dataDir, fileName: fileName})
spec, ok := specByID[int(group.SpecID)]
if !ok {
return "", nil, fmt.Errorf("partition spec %d not found", group.SpecID)
}
dfBuilder, err := iceberg.NewDataFileBuilder(
spec,
iceberg.EntryContentPosDeletes,
absoluteIcebergPath(bucketName, dataPath, "data", fileName),
iceberg.ParquetFile,
group.Partition,
nil, nil,
int64(len(outputRows)),
int64(len(deleteBytes)),
)
if err != nil {
return "", nil, fmt.Errorf("build rewritten delete file: %w", err)
}
rewrittenDeleteFile := dfBuilder.Build()
summary.addFile(rewrittenDeleteFile)
addToSpec(group.SpecID, iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &newSnapID, nil, nil, rewrittenDeleteFile))
deleteFilesWritten++
}
for _, input := range group.Inputs {
summary.removeFile(input.Entry.DataFile())
delEntry := iceberg.NewManifestEntry(
iceberg.EntryStatusDELETED,
&newSnapID,
manifestEntrySeqNum(input.Entry),
manifestEntryFileSeqNum(input.Entry),
input.Entry.DataFile(),
)
addToSpec(group.SpecID, delEntry)
}
rewrittenGroups++
}
if rewrittenGroups == 0 {
return "no position delete files eligible for rewrite", nil, nil
}
for _, entry := range allEqualityEntries {
existingEntry := iceberg.NewManifestEntry(
iceberg.EntryStatusEXISTING,
func() *int64 { id := entry.SnapshotID(); return &id }(),
manifestEntrySeqNum(entry),
manifestEntryFileSeqNum(entry),
entry.DataFile(),
)
addToSpec(entry.DataFile().SpecID(), existingEntry)
}
for _, entry := range allPositionEntries {
if _, replaced := replacedPaths[entry.DataFile().FilePath()]; replaced {
continue
}
existingEntry := iceberg.NewManifestEntry(
iceberg.EntryStatusEXISTING,
func() *int64 { id := entry.SnapshotID(); return &id }(),
manifestEntrySeqNum(entry),
manifestEntryFileSeqNum(entry),
entry.DataFile(),
)
addToSpec(entry.DataFile().SpecID(), existingEntry)
}
sortedSpecIDs := make([]int32, 0, len(specEntriesMap))
for specID := range specEntriesMap {
sortedSpecIDs = append(sortedSpecIDs, specID)
}
sort.Slice(sortedSpecIDs, func(i, j int) bool { return sortedSpecIDs[i] < sortedSpecIDs[j] })
allManifests := make([]iceberg.ManifestFile, 0, len(dataManifests)+len(sortedSpecIDs))
allManifests = append(allManifests, dataManifests...)
for _, specID := range sortedSpecIDs {
spec, ok := specByID[int(specID)]
if !ok {
return "", nil, fmt.Errorf("partition spec %d not found", specID)
}
manifestName := fmt.Sprintf("rewrite-delete-%d-%s-spec%d.avro", newSnapID, artifactSuffix, specID)
manifestPath := absoluteIcebergPath(bucketName, dataPath, "metadata", manifestName)
mf, manifestBytes, err := writeManifestWithContent(
manifestPath,
version,
spec,
meta.CurrentSchema(),
newSnapID,
specEntriesMap[specID].entries,
iceberg.ManifestContentDeletes,
)
if err != nil {
return "", nil, fmt.Errorf("write delete manifest for spec %d: %w", specID, err)
}
if err := saveFilerFile(ctx, filerClient, metaDir, manifestName, manifestBytes); err != nil {
return "", nil, fmt.Errorf("save delete manifest for spec %d: %w", specID, err)
}
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestName})
allManifests = append(allManifests, mf)
}
var manifestListBuf bytes.Buffer
if err := iceberg.WriteManifestList(version, &manifestListBuf, newSnapID, &snapshotID, &seqNum, 0, allManifests); err != nil {
return "", nil, fmt.Errorf("write delete manifest list: %w", err)
}
manifestListName := fmt.Sprintf("snap-%d-%s.avro", newSnapID, artifactSuffix)
if err := saveFilerFile(ctx, filerClient, metaDir, manifestListName, manifestListBuf.Bytes()); err != nil {
return "", nil, fmt.Errorf("save delete manifest list: %w", err)
}
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestListName})
manifestListLocation := absoluteIcebergPath(bucketName, dataPath, "metadata", manifestListName)
err = h.commitWithRetry(ctx, filerClient, bucketName, tablePath, state.MetadataFileName, config, func(currentMeta table.Metadata, builder *table.MetadataBuilder) error {
cs := currentMeta.CurrentSnapshot()
if cs == nil || cs.SnapshotID != snapshotID {
return errStalePlan
}
newSnapshot := &table.Snapshot{
SnapshotID: newSnapID,
ParentSnapshotID: &snapshotID,
SequenceNumber: seqNum,
TimestampMs: newSnapID,
ManifestList: manifestListLocation,
Summary: summary.build(table.OpReplace, cs, map[string]string{
"maintenance": "rewrite_position_delete_files",
"delete-files-rewritten": fmt.Sprintf("%d", deleteFilesRewritten),
"delete-files-written": fmt.Sprintf("%d", deleteFilesWritten),
"delete-groups": fmt.Sprintf("%d", rewrittenGroups),
}),
SchemaID: func() *int {
id := meta.CurrentSchema().ID
return &id
}(),
}
if err := builder.AddSnapshot(newSnapshot); err != nil {
return err
}
return builder.SetSnapshotRef(table.MainBranch, newSnapID, table.BranchRef)
})
if err != nil {
return "", nil, fmt.Errorf("commit delete rewrite: %w", err)
}
committed = true
metrics := map[string]int64{
MetricDeleteFilesRewritten: deleteFilesRewritten,
MetricDeleteFilesWritten: deleteFilesWritten,
MetricDeleteBytesRewritten: deleteBytesRewritten,
MetricDeleteGroupsPlanned: rewrittenGroups,
MetricDeleteGroupsSkipped: skippedGroups,
MetricDurationMs: time.Since(start).Milliseconds(),
}
return fmt.Sprintf(
"rewrote %d position delete files into %d across %d group(s)",
deleteFilesRewritten,
deleteFilesWritten,
rewrittenGroups,
), metrics, nil
}

View File

@@ -0,0 +1,538 @@
package iceberg
import (
"context"
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
)
// tableInfo captures metadata about a table for detection/execution.
type tableInfo struct {
BucketName string
Namespace string
TableName string
TablePath string // namespace/tableName
MetadataFileName string
Metadata table.Metadata
}
// scanTablesForMaintenance enumerates table buckets and their tables,
// evaluating which ones need maintenance based on metadata thresholds.
// When limit > 0 the scan stops after collecting limit+1 results so the
// caller can determine whether more tables remain (HasMore).
func (h *Handler) scanTablesForMaintenance(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
config Config,
bucketFilter, namespaceFilter, tableFilter string,
limit int,
) ([]tableInfo, error) {
var tables []tableInfo
ops, err := parseOperations(config.Operations)
if err != nil {
return nil, fmt.Errorf("parse operations: %w", err)
}
// Compile wildcard matchers once (nil = match all)
bucketMatchers := wildcard.CompileWildcardMatchers(bucketFilter)
nsMatchers := wildcard.CompileWildcardMatchers(namespaceFilter)
tableMatchers := wildcard.CompileWildcardMatchers(tableFilter)
bucketsPath := s3tables.TablesPath
bucketEntries, err := listFilerEntries(ctx, filerClient, bucketsPath, "")
if err != nil {
return nil, fmt.Errorf("list buckets: %w", err)
}
for _, bucketEntry := range bucketEntries {
select {
case <-ctx.Done():
return tables, ctx.Err()
default:
}
if !bucketEntry.IsDirectory || !s3tables.IsTableBucketEntry(bucketEntry) {
continue
}
bucketName := bucketEntry.Name
if !wildcard.MatchesAnyWildcard(bucketMatchers, bucketName) {
continue
}
bucketMaintenance, err := parseMaintenanceConfiguration(bucketEntry.Extended, bucketName)
if err != nil {
glog.Warningf("iceberg maintenance: skipping bucket %s: %v", bucketName, err)
continue
}
// List namespaces within the bucket
bucketPath := path.Join(bucketsPath, bucketName)
nsEntries, err := listFilerEntries(ctx, filerClient, bucketPath, "")
if err != nil {
glog.Warningf("iceberg maintenance: failed to list namespaces in bucket %s: %v", bucketName, err)
continue
}
for _, nsEntry := range nsEntries {
select {
case <-ctx.Done():
return tables, ctx.Err()
default:
}
if !nsEntry.IsDirectory {
continue
}
nsName := nsEntry.Name
if !wildcard.MatchesAnyWildcard(nsMatchers, nsName) {
continue
}
// Skip internal directories
if strings.HasPrefix(nsName, ".") {
continue
}
// List tables within the namespace
nsPath := path.Join(bucketPath, nsName)
tableEntries, err := listFilerEntries(ctx, filerClient, nsPath, "")
if err != nil {
glog.Warningf("iceberg maintenance: failed to list tables in %s/%s: %v", bucketName, nsName, err)
continue
}
for _, tableEntry := range tableEntries {
if !tableEntry.IsDirectory {
continue
}
tblName := tableEntry.Name
if !wildcard.MatchesAnyWildcard(tableMatchers, tblName) {
continue
}
// Check if this entry has table metadata
metadataBytes, ok := tableEntry.Extended[s3tables.ExtendedKeyMetadata]
if !ok || len(metadataBytes) == 0 {
continue
}
tablePath := path.Join(nsName, tblName)
state, err := parseTableMetadataEnvelope(metadataBytes, bucketName, tablePath)
if err != nil {
if errors.Is(err, errForeignFormat) {
glog.V(3).Infof("iceberg maintenance: skipping %s/%s/%s: %v", bucketName, nsName, tblName, err)
} else {
glog.V(2).Infof("iceberg maintenance: skipping %s/%s/%s: cannot parse iceberg metadata: %v", bucketName, nsName, tblName, err)
}
continue
}
if !isIcebergTableEntry(tableEntry.Extended, state.Metadata) {
glog.V(2).Infof("iceberg maintenance: skipping %s/%s/%s: not an iceberg table", bucketName, nsName, tblName)
continue
}
tableMaintenance, err := parseMaintenanceConfiguration(tableEntry.Extended, path.Join(bucketName, tablePath))
if err != nil {
glog.Warningf("iceberg maintenance: skipping %s/%s/%s: %v", bucketName, nsName, tblName, err)
continue
}
maintenance := mergeMaintenanceConfiguration(bucketMaintenance, tableMaintenance)
tableOps := filterDisabledOperations(ops, maintenance)
if len(tableOps) == 0 {
continue
}
effective := resolveTableConfig(config, state.Metadata.Properties(), maintenance)
needsWork, err := h.tableNeedsMaintenance(ctx, filerClient, bucketName, tablePath, state, effective, tableOps)
if err != nil {
glog.V(2).Infof("iceberg maintenance: skipping %s/%s/%s: cannot evaluate maintenance need: %v", bucketName, nsName, tblName, err)
continue
}
if needsWork {
tables = append(tables, tableInfo{
BucketName: bucketName,
Namespace: nsName,
TableName: tblName,
TablePath: tablePath,
MetadataFileName: state.MetadataFileName,
Metadata: state.Metadata,
})
if limit > 0 && len(tables) > limit {
return tables, nil
}
}
}
}
}
return tables, nil
}
func normalizeDetectionConfig(config Config) Config {
config = applyThresholdDefaults(config)
if config.SnapshotRetentionMs <= 0 {
config.SnapshotRetentionMs = hoursToMs(defaultSnapshotRetentionHours)
}
if config.MaxSnapshotsToKeep <= 0 {
config.MaxSnapshotsToKeep = defaultMaxSnapshotsToKeep
}
return config
}
func (h *Handler) tableNeedsMaintenance(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, tablePath string,
state *tableState,
config Config,
ops []string,
) (bool, error) {
config = normalizeDetectionConfig(config)
meta := state.Metadata
dataPath := state.DataPath
metadataFileName := state.MetadataFileName
cachedPlanningIndex := state.PlanningIndex
var predicate *partitionPredicate
if strings.TrimSpace(config.Where) != "" {
needsPredicate := false
for _, op := range ops {
if op == "compact" || op == "rewrite_position_delete_files" || op == "rewrite_manifests" {
needsPredicate = true
break
}
}
if needsPredicate {
var err error
predicate, err = parsePartitionPredicate(config.Where, meta)
if err != nil {
return false, err
}
}
}
_ = predicate // used by rewrite_position_delete_files; planning index handles compact/rewrite_manifests
// Evaluate the metadata-only expiration check first so large tables do not
// pay for manifest reads when snapshot expiry already makes them eligible.
for _, op := range ops {
if op == "expire_snapshots" && needsMaintenance(meta, config) {
return true, nil
}
}
var currentManifests []iceberg.ManifestFile
var manifestsErr error
var manifestsLoaded bool
getCurrentManifests := func() ([]iceberg.ManifestFile, error) {
if manifestsLoaded {
return currentManifests, manifestsErr
}
currentManifests, manifestsErr = loadCurrentManifests(ctx, filerClient, bucketName, dataPath, meta)
manifestsLoaded = true
return currentManifests, manifestsErr
}
computedPlanningIndexes := make(map[string]*planningIndex)
planningIndexLoaded := make(map[string]bool)
planningIndexErrs := make(map[string]error)
getPlanningIndex := func(op string) (*planningIndex, error) {
if planningIndexLoaded[op] {
return computedPlanningIndexes[op], planningIndexErrs[op]
}
planningIndexLoaded[op] = true
manifests, err := getCurrentManifests()
if err != nil {
planningIndexErrs[op] = err
return nil, err
}
index, err := buildPlanningIndexFromManifests(ctx, filerClient, bucketName, dataPath, meta, config, []string{op}, manifests)
if err != nil {
planningIndexErrs[op] = err
return nil, err
}
computedPlanningIndexes[op] = index
if index != nil {
if err := persistPlanningIndex(ctx, filerClient, bucketName, tablePath, index); err != nil {
glog.V(2).Infof("iceberg maintenance: unable to persist planning index for %s/%s: %v", bucketName, tablePath, err)
}
}
return index, nil
}
checkPlanningIndex := func(op string, eligibleFn func(*planningIndex, Config) (bool, bool)) (bool, error) {
if cachedPlanningIndex != nil && cachedPlanningIndex.matchesSnapshot(meta) {
if eligible, ok := eligibleFn(cachedPlanningIndex, config); ok {
return eligible, nil
}
}
index, err := getPlanningIndex(op)
if err != nil {
return false, err
}
if index == nil {
return false, nil
}
eligible, _ := eligibleFn(index, config)
return eligible, nil
}
var opEvalErrors []string
planningIndexErrorReported := false
for _, op := range ops {
switch op {
case "expire_snapshots":
// Handled by the metadata-only check above.
continue
case "compact":
eligible, err := checkPlanningIndex(op, (*planningIndex).compactionEligible)
if err != nil {
if !planningIndexErrorReported {
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
planningIndexErrorReported = true
}
continue
}
if eligible {
return true, nil
}
case "rewrite_position_delete_files":
manifests, err := getCurrentManifests()
if err != nil {
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
continue
}
eligible, err := hasEligibleDeleteRewrite(ctx, filerClient, bucketName, dataPath, manifests, config, meta, predicate)
if err != nil {
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
continue
}
if eligible {
return true, nil
}
case "rewrite_manifests":
eligible, err := checkPlanningIndex(op, (*planningIndex).rewriteManifestsEligible)
if err != nil {
if !planningIndexErrorReported {
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
planningIndexErrorReported = true
}
continue
}
if eligible {
return true, nil
}
case "remove_orphans":
if metadataFileName == "" {
currentState, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
if err != nil {
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
continue
}
metadataFileName = currentState.MetadataFileName
}
orphanCandidates, err := collectOrphanCandidates(ctx, filerClient, bucketName, dataPath, meta, metadataFileName, config.OrphanOlderThanHours)
if err != nil {
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
continue
}
if len(orphanCandidates) > 0 {
return true, nil
}
}
}
if len(opEvalErrors) > 0 {
return false, fmt.Errorf("evaluate maintenance operations: %s", strings.Join(opEvalErrors, "; "))
}
return false, nil
}
func metadataFileNameFromLocation(location string) string {
if location == "" {
return ""
}
name := path.Base(location)
if name == "." || name == "/" {
return ""
}
return name
}
func countDataManifests(manifests []iceberg.ManifestFile) int64 {
var count int64
for _, mf := range manifests {
if mf.ManifestContent() == iceberg.ManifestContentData {
count++
}
}
return count
}
func loadCurrentManifests(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, dataPath string,
meta table.Metadata,
) ([]iceberg.ManifestFile, error) {
currentSnap := meta.CurrentSnapshot()
if currentSnap == nil || currentSnap.ManifestList == "" {
return nil, nil
}
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, currentSnap.ManifestList)
if err != nil {
return nil, fmt.Errorf("read manifest list: %w", err)
}
manifests, err := s3tables.ReadManifestList(manifestListData)
if err != nil {
return nil, fmt.Errorf("parse manifest list: %w", err)
}
return manifests, nil
}
func hasEligibleCompaction(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, dataPath string,
manifests []iceberg.ManifestFile,
config Config,
meta table.Metadata,
predicate *partitionPredicate,
) (bool, error) {
if len(manifests) == 0 {
return false, nil
}
minInputFiles, err := compactionMinInputFiles(config.MinInputFiles)
if err != nil {
return false, err
}
var dataManifests []iceberg.ManifestFile
specIDs := make(map[int32]struct{})
for _, mf := range manifests {
if mf.ManifestContent() != iceberg.ManifestContentData {
continue
}
dataManifests = append(dataManifests, mf)
specIDs[mf.PartitionSpecID()] = struct{}{}
}
if len(dataManifests) == 0 {
return false, nil
}
if len(specIDs) > 1 {
return false, nil
}
specsByID := specByID(meta)
schema := meta.CurrentSchema()
var allEntries []iceberg.ManifestEntry
for _, mf := range dataManifests {
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, mf.FilePath())
if err != nil {
return false, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
}
entries, err := s3tables.ReadManifest(mf, manifestData, true, specsByID, schema)
if err != nil {
return false, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
}
allEntries = append(allEntries, entries...)
}
candidateEntries := allEntries
if predicate != nil {
candidateEntries = make([]iceberg.ManifestEntry, 0, len(allEntries))
for _, entry := range allEntries {
spec, ok := specsByID[int(entry.DataFile().SpecID())]
if !ok {
continue
}
match, err := predicate.Matches(spec, entry.DataFile().Partition())
if err != nil {
return false, err
}
if match {
candidateEntries = append(candidateEntries, entry)
}
}
}
rewritePlan, err := resolveCompactionRewritePlan(config, meta)
if err != nil {
return false, fmt.Errorf("resolve rewrite strategy: %w", err)
}
targetSize := compactionTargetSizeForPlan(config, rewritePlan)
bins := buildCompactionBins(candidateEntries, targetSize, minInputFiles)
bins = filterCompactionBinsByPlan(bins, config, rewritePlan)
return len(bins) > 0, nil
}
func compactionMinInputFiles(minInputFiles int64) (int, error) {
// Ensure the configured value is positive and fits into the platform's int type
if minInputFiles <= 0 {
return 0, fmt.Errorf("min input files must be positive, got %d", minInputFiles)
}
maxInt := int64(^uint(0) >> 1)
if minInputFiles > maxInt {
return 0, fmt.Errorf("min input files %d exceeds platform int size", minInputFiles)
}
return int(minInputFiles), nil
}
// needsMaintenance checks whether snapshot expiration work is needed based on
// metadata-only thresholds.
// It asks execution what it would do rather than reimplementing the rules:
// expiry always requires a snapshot to be past the retention window, so a
// table over the snapshot quota whose snapshots are all young, or all pinned by
// refs, would otherwise be proposed for a job that can only no-op.
func needsMaintenance(meta table.Metadata, config Config) bool {
return len(snapshotsToExpire(meta, config, time.Now().UnixMilli())) > 0
}
// buildMaintenanceProposal creates a JobProposal for a table needing maintenance.
func (h *Handler) buildMaintenanceProposal(t tableInfo, filerGrpcAddress, resourceGroup string) *plugin_pb.JobProposal {
dedupeKey := fmt.Sprintf("iceberg_maintenance:%s/%s/%s", t.BucketName, t.Namespace, t.TableName)
snapshotCount := len(t.Metadata.Snapshots())
summary := fmt.Sprintf("Maintain %s/%s/%s (%d snapshots)", t.BucketName, t.Namespace, t.TableName, snapshotCount)
proposal := &plugin_pb.JobProposal{
ProposalId: fmt.Sprintf("iceberg-%s-%s-%s-%d", t.BucketName, t.Namespace, t.TableName, time.Now().UnixMilli()),
DedupeKey: dedupeKey,
JobType: jobType,
Priority: plugin_pb.JobPriority_JOB_PRIORITY_NORMAL,
Summary: summary,
Parameters: map[string]*plugin_pb.ConfigValue{
"bucket_name": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.BucketName}},
"namespace": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.Namespace}},
"table_name": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.TableName}},
"table_path": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.TablePath}},
"filer_address": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: filerGrpcAddress}},
},
Labels: map[string]string{
"bucket": t.BucketName,
"namespace": t.Namespace,
"table": t.TableName,
},
}
if resourceGroup != "" {
proposal.Parameters["resource_group"] = &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_StringValue{StringValue: resourceGroup}}
proposal.Labels["resource_group"] = resourceGroup
}
return proposal
}

View File

@@ -0,0 +1,484 @@
package iceberg
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// filerFileEntry holds a non-directory entry with its full directory path.
type filerFileEntry struct {
Dir string
Entry *filer_pb.Entry
}
var initGlobalHTTPClientOnce sync.Once
type singleFilerClient struct {
client filer_pb.SeaweedFilerClient
}
func (c singleFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
return fn(c.client)
}
func (c singleFilerClient) AdjustedUrl(location *filer_pb.Location) string {
if location == nil {
return ""
}
if location.PublicUrl != "" {
return location.PublicUrl
}
return location.Url
}
func (c singleFilerClient) GetDataCenter() string {
return ""
}
// listFilerEntries lists all entries in a directory.
func listFilerEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, prefix string) ([]*filer_pb.Entry, error) {
var entries []*filer_pb.Entry
var lastFileName string
limit := uint32(10000)
for {
resp, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
Directory: dir,
Prefix: prefix,
StartFromFileName: lastFileName,
InclusiveStartFrom: lastFileName == "",
Limit: limit,
})
if err != nil {
// Treat not-found as empty directory; propagate other errors.
if status.Code(err) == codes.NotFound {
return entries, nil
}
return entries, fmt.Errorf("list entries in %s: %w", dir, err)
}
count := 0
for {
entry, recvErr := resp.Recv()
if recvErr != nil {
if recvErr == io.EOF {
break
}
return entries, fmt.Errorf("recv entry in %s: %w", dir, recvErr)
}
if entry.Entry != nil {
entries = append(entries, entry.Entry)
lastFileName = entry.Entry.Name
count++
}
}
if count < int(limit) {
break
}
}
return entries, nil
}
// walkFilerEntries recursively lists all non-directory entries under dir.
func walkFilerEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir string) ([]filerFileEntry, error) {
entries, err := listFilerEntries(ctx, client, dir, "")
if err != nil {
return nil, err
}
var result []filerFileEntry
for _, entry := range entries {
if entry.IsDirectory {
subDir := path.Join(dir, entry.Name)
subEntries, err := walkFilerEntries(ctx, client, subDir)
if err != nil {
glog.V(2).Infof("iceberg maintenance: cannot walk %s: %v", subDir, err)
continue
}
result = append(result, subEntries...)
} else {
result = append(result, filerFileEntry{Dir: dir, Entry: entry})
}
}
return result, nil
}
// loadCurrentMetadata loads and parses the current Iceberg metadata from the table entry's xattr.
func loadCurrentMetadata(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string) (*tableState, error) {
dir := path.Join(s3tables.TablesPath, bucketName, path.Dir(tablePath))
name := path.Base(tablePath)
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
return nil, fmt.Errorf("lookup table entry %s/%s: %w", dir, name, err)
}
if resp == nil || resp.Entry == nil {
return nil, fmt.Errorf("table entry not found: %s/%s", dir, name)
}
metadataBytes, ok := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]
if !ok || len(metadataBytes) == 0 {
return nil, fmt.Errorf("no metadata xattr on table entry %s/%s", dir, name)
}
state, err := parseTableMetadataEnvelope(metadataBytes, bucketName, tablePath)
if err != nil {
return nil, err
}
// Refuse to rewrite an entry that only looks like an Iceberg table. Every
// operation here rewrites or deletes files under the table location, and a
// foreign format's files are unreferenced by Iceberg metadata by definition.
if !isIcebergTableEntry(resp.Entry.Extended, state.Metadata) {
return nil, fmt.Errorf("entry %s/%s is not an iceberg table", dir, name)
}
return state, nil
}
// loadFileByIcebergPath loads a file from the filer given an Iceberg-style path.
// dataPath is the bucket-relative directory the table's files live in.
func loadFileByIcebergPath(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, dataPath, icebergPath string) ([]byte, error) {
fullPath, err := icebergFilerPath(bucketName, dataPath, icebergPath)
if err != nil {
return nil, err
}
dir, fileName := path.Dir(fullPath), path.Base(fullPath)
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: fileName,
})
if err != nil {
return nil, fmt.Errorf("lookup %s/%s: %w", dir, fileName, err)
}
if resp == nil || resp.Entry == nil {
return nil, fmt.Errorf("file not found: %s/%s", dir, fileName)
}
if len(resp.Entry.Content) > 0 || len(resp.Entry.Chunks) == 0 {
return resp.Entry.Content, nil
}
initGlobalHTTPClientOnce.Do(util_http.InitGlobalHttpClient)
reader := filer.NewFileReader(singleFilerClient{client: client}, resp.Entry)
if closer, ok := reader.(io.Closer); ok {
defer closer.Close()
}
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("read chunked file %s/%s: %w", dir, fileName, err)
}
return data, nil
}
// normalizeIcebergPath converts an Iceberg path (an S3 URL, an absolute filer
// path, or a path relative to the table root) into a path relative to the
// bucket root, e.g. "ns/table/metadata/snap-1.avro". Absolute references are
// resolved from the bucket root rather than from dataPath: an Iceberg table
// records where its files actually are, and a client writing through the REST
// catalog may place them outside the catalog path. The bucket-relative form is
// the canonical key for comparing references that mix both spellings.
func normalizeIcebergPath(icebergPath, bucketName, dataPath string) (string, error) {
p := icebergPath
absolute := false
if idx := strings.Index(p, "://"); idx >= 0 {
p = p[idx+3:]
absolute = true
} else if strings.HasPrefix(p, "/") {
absolute = true
}
p = strings.TrimPrefix(p, "/")
if absolute {
rest, ok := cutPathPrefix(p, bucketName)
if !ok {
// Filer-style references carry the /buckets prefix ahead of the bucket.
if withoutTablesPath, hasTablesPath := cutPathPrefix(p, strings.TrimPrefix(s3tables.TablesPath, "/")); hasTablesPath {
rest, ok = cutPathPrefix(withoutTablesPath, bucketName)
}
}
if !ok {
return "", fmt.Errorf("iceberg path %q is outside bucket %q", icebergPath, bucketName)
}
p = rest
} else {
if clean := path.Clean(p); clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("invalid iceberg path %q", icebergPath)
}
p = path.Join(dataPath, p)
}
p = path.Clean(p)
if p == "." || p == ".." || strings.HasPrefix(p, "../") {
return "", fmt.Errorf("invalid iceberg path %q", icebergPath)
}
return p, nil
}
// cutPathPrefix removes a leading path prefix at a path boundary.
func cutPathPrefix(p, segment string) (string, bool) {
if segment == "" {
return p, false
}
rest, ok := strings.CutPrefix(p, segment+"/")
return rest, ok
}
// icebergFilerPath resolves an Iceberg path to the filer path holding the file.
func icebergFilerPath(bucketName, dataPath, icebergPath string) (string, error) {
relPath, err := normalizeIcebergPath(icebergPath, bucketName, dataPath)
if err != nil {
return "", err
}
return path.Join(s3tables.TablesPath, bucketName, relPath), nil
}
// absoluteIcebergPath is the inverse of normalizeIcebergPath: it builds the
// absolute s3:// URI for a bucket-relative path. The Iceberg spec requires
// absolute locations in metadata — strict readers (Spark/Trino via S3FileIO)
// reject paths with no scheme.
func absoluteIcebergPath(bucketName string, elem ...string) string {
return "s3://" + path.Join(append([]string{bucketName}, elem...)...)
}
const (
// inlineContentLimit is the most saveFilerFile keeps in Entry.Content.
// Manifests and metadata JSON are small and stay inline; a compacted data
// file runs to hundreds of MB, and inlining that stores the parquet bytes
// verbatim in the filer store and ships them again through the metadata
// change log as one oversized event.
inlineContentLimit = 256 * 1024
// filerFileChunkSize is the upload chunk size for anything over the limit.
filerFileChunkSize = 8 * 1024 * 1024
)
// saveFilerFile saves a file to the filer, inline when it is small and as
// volume chunks when it is not.
func saveFilerFile(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, fileName string, content []byte) error {
entry := &filer_pb.Entry{
Name: fileName,
Attributes: &filer_pb.FuseAttributes{
Mtime: time.Now().Unix(),
Crtime: time.Now().Unix(),
FileMode: uint32(0644),
FileSize: uint64(len(content)),
},
}
if len(content) <= inlineContentLimit {
entry.Content = content
} else {
chunks, err := uploadFilerChunks(ctx, client, path.Join(dir, fileName), content)
if err != nil {
return fmt.Errorf("upload %s/%s: %w", dir, fileName, err)
}
entry.Chunks = chunks
}
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: dir,
Entry: entry,
})
if err != nil {
return fmt.Errorf("create entry %s/%s: %w", dir, fileName, err)
}
if resp.Error != "" {
return fmt.Errorf("create entry %s/%s: %s", dir, fileName, resp.Error)
}
return nil
}
// uploadFilerChunks writes content to volume servers, assigning through the
// filer so the entry's storage rules apply.
func uploadFilerChunks(ctx context.Context, client filer_pb.SeaweedFilerClient, fullPath string, content []byte) ([]*filer_pb.FileChunk, error) {
assignFn := func(ctx context.Context, count int, expectedDataSize uint64) (*operation.VolumeAssignRequest, *operation.AssignResult, error) {
resp, err := client.AssignVolume(ctx, &filer_pb.AssignVolumeRequest{
Count: int32(count),
Path: fullPath,
ExpectedDataSize: expectedDataSize,
})
if err != nil {
return nil, nil, err
}
if resp.Error != "" {
return nil, nil, errors.New(resp.Error)
}
if resp.Location == nil || resp.FileId == "" {
return nil, nil, fmt.Errorf("assign volume returned no location")
}
return nil, &operation.AssignResult{
Fid: resp.FileId,
Url: resp.Location.Url,
PublicUrl: resp.Location.PublicUrl,
Count: uint64(count),
Auth: security.EncodedJwt(resp.Auth),
Fsync: resp.Fsync,
}, nil
}
initGlobalHTTPClientOnce.Do(util_http.InitGlobalHttpClient)
result, err := operation.UploadReaderInChunks(ctx, util.NewBytesReader(content), &operation.ChunkedUploadOption{
ChunkSize: filerFileChunkSize,
AssignFunc: assignFn,
})
if err != nil {
// Chunks uploaded before the failure are orphaned: nothing references
// them, so name them for fsck rather than losing them silently.
if result != nil {
for _, chunk := range result.FileChunks {
glog.Warningf("iceberg: orphan chunk %s from failed upload of %s", chunk.GetFileIdString(), fullPath)
}
}
return nil, err
}
return result.FileChunks, nil
}
// deleteFilerFile deletes a file from the filer.
func deleteFilerFile(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, fileName string) error {
return filer_pb.DoRemove(ctx, client, dir, fileName, true, false, true, false, nil)
}
// updateTableMetadataXattr updates the table entry's metadata xattr with
// the new Iceberg metadata. It verifies the stored metadataVersion before
// writing and passes the previous metadata xattr back to the filer as a
// server-side precondition so concurrent writers fail with a retryable
// metadata version conflict.
// newMetadataLocation is the absolute location of the new metadata file
// (e.g. "s3://bucket/ns/table/metadata/v3.metadata.json"), matching the form
// the S3 Tables catalog stores.
func updateTableMetadataXattr(ctx context.Context, client filer_pb.SeaweedFilerClient, tableDir string, expectedVersion int, newFullMetadata []byte, newMetadataLocation string) error {
tableName := path.Base(tableDir)
parentDir := path.Dir(tableDir)
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: parentDir,
Name: tableName,
})
if err != nil {
return fmt.Errorf("lookup table entry: %w", err)
}
if resp == nil || resp.Entry == nil {
return fmt.Errorf("table entry not found")
}
existingXattr, ok := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]
if !ok {
return fmt.Errorf("no metadata xattr on table entry")
}
// Parse existing xattr, update fullMetadata
var internalMeta map[string]json.RawMessage
if err := json.Unmarshal(existingXattr, &internalMeta); err != nil {
return fmt.Errorf("unmarshal existing xattr: %w", err)
}
// Verify the stored metadataVersion matches what we expect before issuing
// the conditional UpdateEntry request below.
versionRaw, ok := internalMeta["metadataVersion"]
if !ok {
return fmt.Errorf("%w: metadataVersion field missing from xattr", errMetadataVersionConflict)
}
var storedVersion int
if err := json.Unmarshal(versionRaw, &storedVersion); err != nil {
return fmt.Errorf("%w: cannot parse metadataVersion: %v", errMetadataVersionConflict, err)
}
if storedVersion != expectedVersion {
return fmt.Errorf("%w: expected version %d, found %d", errMetadataVersionConflict, expectedVersion, storedVersion)
}
// Update the metadata.fullMetadata field
var metadataObj map[string]json.RawMessage
if raw, ok := internalMeta["metadata"]; ok {
if err := json.Unmarshal(raw, &metadataObj); err != nil {
return fmt.Errorf("unmarshal metadata object: %w", err)
}
} else {
metadataObj = make(map[string]json.RawMessage)
}
metadataObj["fullMetadata"] = newFullMetadata
metadataJSON, err := json.Marshal(metadataObj)
if err != nil {
return fmt.Errorf("marshal metadata object: %w", err)
}
internalMeta["metadata"] = metadataJSON
// Increment version
newVersion := expectedVersion + 1
versionJSON, _ := json.Marshal(newVersion)
internalMeta["metadataVersion"] = versionJSON
// Update modifiedAt
modifiedAt, _ := json.Marshal(time.Now().Format(time.RFC3339Nano))
internalMeta["modifiedAt"] = modifiedAt
// Update metadataLocation to point to the new metadata file
metaLocJSON, _ := json.Marshal(newMetadataLocation)
internalMeta["metadataLocation"] = metaLocJSON
// Regenerate versionToken for consistency with the S3 Tables catalog
tokenJSON, _ := json.Marshal(generateIcebergVersionToken())
internalMeta["versionToken"] = tokenJSON
updatedXattr, err := json.Marshal(internalMeta)
if err != nil {
return fmt.Errorf("marshal updated xattr: %w", err)
}
// Assert the whole attribute set, not just the version: this rewrites the
// entry from the snapshot above, so a narrower precondition would delete an
// attribute a concurrent writer created, such as a maintenance configuration.
expectedExtended := s3tables.SnapshotExtended(resp.Entry.Extended)
resp.Entry.Extended[s3tables.ExtendedKeyMetadata] = updatedXattr
resp.Entry.Extended[s3tables.ExtendedKeyMetadataVersion] = metadataVersionXattr(newVersion)
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: parentDir,
Entry: resp.Entry,
ExpectedExtended: expectedExtended,
})
if err != nil {
if status.Code(err) == codes.FailedPrecondition {
return fmt.Errorf("%w: table metadata changed during update", errMetadataVersionConflict)
}
return fmt.Errorf("update table entry: %w", err)
}
return nil
}
func metadataVersionXattr(version int) []byte {
return []byte(strconv.Itoa(version))
}
// generateIcebergVersionToken produces a random hex token, mirroring the
// logic in s3tables.generateVersionToken (which is unexported).
func generateIcebergVersionToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}

View File

@@ -0,0 +1,170 @@
package iceberg
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
// fakeVolumeServer accepts chunk uploads and records what it received, so a
// test can check the bytes actually left the filer entry.
type fakeVolumeServer struct {
mu sync.Mutex
server *httptest.Server
parts map[string][]byte // fid → data
}
func startFakeVolumeServer(t *testing.T) *fakeVolumeServer {
t.Helper()
initGlobalHTTPClientOnce.Do(util_http.InitGlobalHttpClient)
v := &fakeVolumeServer{parts: make(map[string][]byte)}
v.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fid := strings.TrimPrefix(r.URL.Path, "/")
// The uploader sends the payload as a single form-data part named
// "file" with an empty filename, which FormFile will not match.
reader, err := r.MultipartReader()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
part, err := reader.NextPart()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
data, err := io.ReadAll(part)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
v.mu.Lock()
v.parts[fid] = data
v.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"name": fid, "size": len(data)})
}))
t.Cleanup(v.server.Close)
return v
}
func (v *fakeVolumeServer) hostPort(t *testing.T) string {
t.Helper()
u, err := url.Parse(v.server.URL)
if err != nil {
t.Fatalf("parse fake volume url: %v", err)
}
return u.Host
}
func (v *fakeVolumeServer) uploaded() int {
v.mu.Lock()
defer v.mu.Unlock()
return len(v.parts)
}
func (v *fakeVolumeServer) dataFor(fid string) []byte {
v.mu.Lock()
defer v.mu.Unlock()
return v.parts[fid]
}
// AssignVolume on the fake filer hands out sequential fids pointing at the
// fake volume server.
func (f *fakeFilerServer) enableAssign(volumeServer string) {
f.mu.Lock()
defer f.mu.Unlock()
f.assignVolumeServer = volumeServer
}
func TestSaveFilerFileKeepsSmallContentInline(t *testing.T) {
fakeServer, client := startFakeFiler(t)
content := []byte(strings.Repeat("m", inlineContentLimit))
if err := saveFilerFile(context.Background(), client, "/buckets/lake/ns/tbl/metadata", "snap.avro", content); err != nil {
t.Fatalf("saveFilerFile: %v", err)
}
entry := fakeServer.getEntry("/buckets/lake/ns/tbl/metadata", "snap.avro")
if entry == nil {
t.Fatal("entry not created")
}
if len(entry.Content) != len(content) {
t.Errorf("inline content is %d bytes, want %d", len(entry.Content), len(content))
}
if len(entry.Chunks) != 0 {
t.Errorf("small file got %d chunks, want inline only", len(entry.Chunks))
}
}
// A compacted data file must land in volumes, not in Entry.Content: an inline
// entry that big is stored verbatim by the filer store and rides the metadata
// change log as one event too large for a volume server to accept.
func TestSaveFilerFileChunksLargeContent(t *testing.T) {
fakeServer, client := startFakeFiler(t)
volumeServer := startFakeVolumeServer(t)
fakeServer.enableAssign(volumeServer.hostPort(t))
content := make([]byte, 3*filerFileChunkSize/2)
for i := range content {
content[i] = byte(i)
}
if err := saveFilerFile(context.Background(), client, "/buckets/lake/ns/tbl/data", "compact-1.parquet", content); err != nil {
t.Fatalf("saveFilerFile: %v", err)
}
entry := fakeServer.getEntry("/buckets/lake/ns/tbl/data", "compact-1.parquet")
if entry == nil {
t.Fatal("entry not created")
}
if len(entry.Content) != 0 {
t.Errorf("large file kept %d bytes inline, want none", len(entry.Content))
}
if len(entry.Chunks) != 2 {
t.Fatalf("got %d chunks, want 2", len(entry.Chunks))
}
if entry.Attributes.FileSize != uint64(len(content)) {
t.Errorf("file size %d, want %d", entry.Attributes.FileSize, len(content))
}
if volumeServer.uploaded() != 2 {
t.Errorf("volume server received %d uploads, want 2", volumeServer.uploaded())
}
// Reassembling the chunks in offset order gives back the original bytes.
rejoined := make([]byte, len(content))
for _, chunk := range entry.Chunks {
data := volumeServer.dataFor(chunk.FileId)
if uint64(len(data)) != chunk.Size {
t.Fatalf("chunk %s holds %d bytes, entry says %d", chunk.FileId, len(data), chunk.Size)
}
copy(rejoined[chunk.Offset:], data)
}
if string(rejoined) != string(content) {
t.Error("reassembled chunks differ from the original content")
}
}
// A failed upload must not silently fall back to an inline entry.
func TestSaveFilerFileFailsWhenUploadFails(t *testing.T) {
fakeServer, client := startFakeFiler(t)
content := make([]byte, filerFileChunkSize+1)
err := saveFilerFile(context.Background(), client, "/buckets/lake/ns/tbl/data", "compact-2.parquet", content)
if err == nil {
t.Fatal("expected an error when no volume can be assigned")
}
if entry := fakeServer.getEntry("/buckets/lake/ns/tbl/data", "compact-2.parquet"); entry != nil {
t.Errorf("entry created despite the failed upload: %d inline bytes", len(entry.Content))
}
}

View File

@@ -0,0 +1,113 @@
package iceberg
import (
"context"
"encoding/json"
"path"
"testing"
"time"
"github.com/apache/iceberg-go"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
)
// seedAdapterRegisteredLanceTable builds what the Lance namespace's Iceberg REST
// adapter leaves behind: a real Iceberg table with a placeholder schema and
// table_type=lance, whose directory holds a Lance dataset rather than Iceberg
// data files.
func seedAdapterRegisteredLanceTable(t *testing.T, properties iceberg.Properties) (*s3tablestest.MemFiler, string) {
t.Helper()
const bucket, namespace, table = "vectors", "ml", "embeddings"
filer := s3tablestest.Start(t)
filer.Put(s3tables.TablesPath, bucket, map[string][]byte{s3tables.ExtendedKeyTableBucket: []byte("{}")})
nsMeta, err := json.Marshal(map[string]any{"namespace": []string{namespace}})
if err != nil {
t.Fatalf("marshal namespace metadata: %v", err)
}
filer.Put(s3tables.GetTableBucketPath(bucket), namespace, map[string][]byte{s3tables.ExtendedKeyMetadata: nsMeta})
full, err := json.Marshal(buildTestMetadata(t, nil, nil, 0, properties, nil, nil))
if err != nil {
t.Fatalf("marshal iceberg metadata: %v", err)
}
envelope, err := json.Marshal(map[string]any{
"metadataVersion": 1,
"metadataLocation": "s3://" + bucket + "/" + namespace + "/" + table + "/metadata/v1.metadata.json",
"metadata": map[string]any{"fullMetadata": json.RawMessage(full)},
})
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
filer.Put(s3tables.GetNamespacePath(bucket, namespace), table,
map[string][]byte{s3tables.ExtendedKeyMetadata: envelope})
// The Lance dataset. Its fragments live in data/, the same directory the
// orphan cleaner walks, and the Iceberg metadata references none of them.
tablePath := s3tables.GetTablePath(bucket, namespace, table)
old := time.Now().Add(-30 * 24 * time.Hour)
filer.Put(tablePath, "data", nil)
filer.PutFile(path.Join(tablePath, "data"), "01111110101110001101011164cadc43919eace9c608107bd9.lance", old)
filer.Put(tablePath, "_versions", nil)
filer.PutFile(path.Join(tablePath, "_versions"), "1.manifest", old)
filer.Put(tablePath, "metadata", nil)
filer.PutFile(path.Join(tablePath, "metadata"), "v1.metadata.json", old)
return filer, path.Join(namespace, table)
}
// The hazard this guards against, stated as a test: every fragment of a Lance
// dataset is unreferenced by the Iceberg metadata sitting beside it, so orphan
// cleanup would delete the whole dataset.
func TestOrphanCleanupWouldDeleteAnAdapterRegisteredLanceDataset(t *testing.T) {
filer, tablePath := seedAdapterRegisteredLanceTable(t, iceberg.Properties{tableTypeProperty: "lance"})
meta := buildTestMetadata(t, nil, nil, 0, iceberg.Properties{tableTypeProperty: "lance"}, nil, nil)
candidates, err := collectOrphanCandidates(context.Background(), filer.Client, "vectors", tablePath,
meta, "v1.metadata.json", defaultOrphanOlderThanHours)
if err != nil {
t.Fatalf("collect orphan candidates: %v", err)
}
var doomed []string
for _, c := range candidates {
doomed = append(doomed, path.Join(c.Dir, c.Entry.Name))
}
if len(doomed) == 0 {
t.Fatal("expected the Lance fragments to look like orphans; without that this guard is pointless")
}
for _, name := range doomed {
if path.Ext(name) == ".lance" {
return
}
}
t.Fatalf("expected a .lance fragment among the orphan candidates, got %v", doomed)
}
// And the guard that stops it: the scan never reaches the table.
func TestScanSkipsAnAdapterRegisteredLanceTable(t *testing.T) {
handler := &Handler{}
config := normalizeDetectionConfig(Config{Operations: "all"})
filer, _ := seedAdapterRegisteredLanceTable(t, iceberg.Properties{tableTypeProperty: "lance"})
tables, err := handler.scanTablesForMaintenance(context.Background(), filer.Client, config, "", "", "", 0)
if err != nil {
t.Fatalf("scan: %v", err)
}
if len(tables) != 0 {
t.Fatalf("scan picked up a lance table: %v", tables)
}
// An ordinary Iceberg table in the same shape is still scanned, so the guard
// is not simply skipping everything.
filer, _ = seedAdapterRegisteredLanceTable(t, nil)
tables, err = handler.scanTablesForMaintenance(context.Background(), filer.Client, config, "", "", "", 0)
if err != nil {
t.Fatalf("scan: %v", err)
}
if len(tables) != 1 {
t.Fatalf("scan skipped an iceberg table: %v", tables)
}
}

View File

@@ -0,0 +1,73 @@
package iceberg
import (
"errors"
"testing"
"github.com/apache/iceberg-go"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// A Lance dataset registered through the Lance namespace's Iceberg REST adapter
// arrives as an Iceberg table with a placeholder schema and table_type=lance,
// and keeps its fragments under data/ where the orphan cleaner walks. Every
// fragment is unreferenced by the Iceberg metadata, so a maintenance pass would
// delete the dataset.
func TestIsIcebergTableEntry(t *testing.T) {
cases := []struct {
name string
extended map[string][]byte
properties iceberg.Properties
want bool
}{
{
name: "plain table",
want: true,
},
{
name: "iceberg table_type is honoured case-insensitively",
properties: iceberg.Properties{tableTypeProperty: "ICEBERG"},
want: true,
},
{
name: "lance table registered through the iceberg adapter",
properties: iceberg.Properties{tableTypeProperty: "lance"},
want: false,
},
{
name: "view",
extended: map[string][]byte{s3tables.ExtendedKeyEntryType: []byte(s3tables.EntryTypeView)},
want: false,
},
{
name: "explicit table marker",
extended: map[string][]byte{s3tables.ExtendedKeyEntryType: []byte(s3tables.EntryTypeTable)},
want: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
meta := buildTestMetadata(t, nil, nil, 0, c.properties, nil, nil)
if got := isIcebergTableEntry(c.extended, meta); got != c.want {
t.Fatalf("isIcebergTableEntry() = %v, want %v", got, c.want)
}
})
}
}
// A table the namespace created as LANCE carries no Iceberg metadata at all.
// Without reading the catalog's own format field the parse below just fails,
// and the table gets skipped as though its metadata were damaged.
func TestParseTableMetadataEnvelopeRejectsForeignFormats(t *testing.T) {
lance := []byte(`{"name":"vectors","namespace":"ml","format":"LANCE","metadataLocation":"s3://b/ml/vectors"}`)
if _, err := parseTableMetadataEnvelope(lance, "b", "ml/vectors"); !errors.Is(err, errForeignFormat) {
t.Fatalf("parse of a LANCE entry = %v, want errForeignFormat", err)
}
// An entry with no format recorded predates the field and is still Iceberg's.
legacy := []byte(`{"metadataVersion":1}`)
if _, err := parseTableMetadataEnvelope(legacy, "b", "ml/t"); err == nil || errors.Is(err, errForeignFormat) {
t.Fatalf("parse of a legacy entry = %v, want a plain parse failure", err)
}
}

View File

@@ -0,0 +1,739 @@
package iceberg
import (
"context"
"fmt"
"path"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/timestamppb"
)
func init() {
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
JobType: jobType,
Category: pluginworker.CategoryHeavy,
Aliases: []string{"iceberg-maintenance", "iceberg.maintenance", "iceberg"},
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
return NewHandler(opts.GrpcDialOption), nil
},
})
}
// Handler implements the JobHandler interface for Iceberg table maintenance:
// snapshot expiration, orphan file removal, and manifest rewriting.
type Handler struct {
grpcDialOption grpc.DialOption
}
const filerConnectTimeout = 5 * time.Second
// NewHandler creates a new handler for iceberg table maintenance.
func NewHandler(grpcDialOption grpc.DialOption) *Handler {
return &Handler{grpcDialOption: grpcDialOption}
}
func (h *Handler) Capability() *plugin_pb.JobTypeCapability {
return &plugin_pb.JobTypeCapability{
JobType: jobType,
CanDetect: true,
CanExecute: true,
MaxDetectionConcurrency: 1,
MaxExecutionConcurrency: 4,
DisplayName: "Iceberg Maintenance",
Description: "Compacts data, rewrites delete files, expires snapshots, removes orphans, and rewrites manifests for Iceberg tables in S3 table buckets",
Weight: 50,
}
}
func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
return &plugin_pb.JobTypeDescriptor{
JobType: jobType,
DisplayName: "Iceberg Maintenance",
Description: "Automated maintenance for Iceberg tables: data compaction, delete-file rewrite, snapshot expiration, orphan removal, and manifest rewriting",
Icon: "fas fa-snowflake",
DescriptorVersion: 1,
AdminConfigForm: &plugin_pb.ConfigForm{
FormId: "iceberg-maintenance-admin",
Title: "Iceberg Maintenance Admin Config",
Description: "Admin-side controls for Iceberg table maintenance scope.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "scope",
Title: "Scope",
Description: "Filters to restrict which tables are scanned for maintenance.",
Fields: []*plugin_pb.ConfigField{
{
Name: "bucket_filter",
Label: "Bucket Filter",
Description: "Comma-separated wildcard patterns for table buckets (* and ? supported). Blank = all.",
Placeholder: "prod-*, staging-*",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "namespace_filter",
Label: "Namespace Filter",
Description: "Comma-separated wildcard patterns for namespaces (* and ? supported). Blank = all.",
Placeholder: "analytics, events-*",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "table_filter",
Label: "Table Filter",
Description: "Comma-separated wildcard patterns for table names (* and ? supported). Blank = all.",
Placeholder: "clicks, orders-*",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
},
},
{
SectionId: "resources",
Title: "Resource Groups",
Description: "Controls for fair proposal distribution across buckets or namespaces.",
Fields: []*plugin_pb.ConfigField{
{
Name: "resource_group_by",
Label: "Group Proposals By",
Description: "When set, detection emits proposals in round-robin order across the selected resource group.",
Placeholder: "none, bucket, namespace, or bucket_namespace",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "max_tables_per_resource_group",
Label: "Max Tables Per Group",
Description: "Optional cap on how many proposals a single resource group can receive in one detection run. Zero disables the cap.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
},
},
},
},
DefaultValues: map[string]*plugin_pb.ConfigValue{
"bucket_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
"namespace_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
"table_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
"resource_group_by": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: resourceGroupNone}},
"max_tables_per_resource_group": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
},
},
WorkerConfigForm: &plugin_pb.ConfigForm{
FormId: "iceberg-maintenance-worker",
Title: "Iceberg Maintenance Worker Config",
Description: "Worker-side thresholds for maintenance operations.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "snapshots",
Title: "Snapshot Expiration",
Description: "Controls for automatic snapshot cleanup.",
Fields: []*plugin_pb.ConfigField{
{
Name: "snapshot_retention_hours",
Label: "Retention (hours)",
Description: "Expire snapshots older than this many hours.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
},
{
Name: "max_snapshots_to_keep",
Label: "Max Snapshots",
Description: "Always keep at least this many most recent snapshots.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
},
},
},
{
SectionId: "compaction",
Title: "Data Compaction",
Description: "Controls for bin-packing or sorting small Parquet data files.",
Fields: []*plugin_pb.ConfigField{
{
Name: "target_file_size_mb",
Label: "Target File Size (MB)",
Description: "Files smaller than this (in megabytes) are candidates for compaction.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
},
{
Name: "min_input_files",
Label: "Min Input Files",
Description: "Minimum number of small files in a partition to trigger compaction.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
},
{
Name: "apply_deletes",
Label: "Apply Deletes",
Description: "When true, compaction applies position and equality deletes to data files. When false, tables with delete manifests are skipped.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_BOOL,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TOGGLE,
},
{
Name: "rewrite_strategy",
Label: "Rewrite Strategy",
Description: "binpack keeps the existing row order; sort rewrites each compaction bin using the Iceberg table sort order; auto sorts tables that declare one and bin-packs the rest.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
Placeholder: "binpack or sort",
},
{
Name: "sort_buffer_rows",
Label: "Sort Buffer Rows",
Description: "Rows a sorted rewrite holds in memory before spilling a sorted run to disk. The writer merges at most 32K runs, so this also bounds how many rows one output file can hold.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: minSortBufferRows}},
},
{
Name: "sort_spill_dir",
Label: "Sort Spill Directory",
Description: "Directory holding a sorted rewrite's temporary runs. Empty uses the system temp directory.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "sort_max_input_mb",
Label: "Sort Max Input (MB)",
Description: "Optional hard cap for the total bytes in a sorted compaction bin. Zero = no extra cap beyond binning.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
},
},
},
{
SectionId: "delete_rewrite",
Title: "Delete Rewrite",
Description: "Controls for rewriting small position-delete files into fewer larger files.",
Fields: []*plugin_pb.ConfigField{
{
Name: "delete_target_file_size_mb",
Label: "Delete Target File Size (MB)",
Description: "Target size for rewritten position-delete files.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
},
{
Name: "delete_min_input_files",
Label: "Delete Min Input Files",
Description: "Minimum number of position-delete files in a group before rewrite is triggered.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
},
{
Name: "delete_max_file_group_size_mb",
Label: "Delete Max Group Size (MB)",
Description: "Skip rewriting delete groups larger than this bound.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
},
{
Name: "delete_max_output_files",
Label: "Delete Max Output Files",
Description: "Maximum number of rewritten delete files a single group may produce.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
},
},
},
{
SectionId: "orphans",
Title: "Orphan Removal",
Description: "Controls for orphan file cleanup.",
Fields: []*plugin_pb.ConfigField{
{
Name: "orphan_older_than_hours",
Label: "Safety Window (hours)",
Description: "Only remove orphan files older than this many hours.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
},
},
},
{
SectionId: "manifests",
Title: "Manifest Rewriting",
Description: "Controls for merging small manifests.",
Fields: []*plugin_pb.ConfigField{
{
Name: "min_manifests_to_rewrite",
Label: "Min Manifests",
Description: "Minimum number of manifests before rewriting is triggered.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
},
},
},
{
SectionId: "general",
Title: "General",
Description: "General maintenance settings.",
Fields: []*plugin_pb.ConfigField{
{
Name: "max_commit_retries",
Label: "Max Commit Retries",
Description: "Maximum number of commit retries on version conflict.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 20}},
},
{
Name: "operations",
Label: "Operations",
Description: "Comma-separated list of operations to run: compact, rewrite_position_delete_files, expire_snapshots, remove_orphans, rewrite_manifests, or 'all'.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "table_properties_override",
Label: "Table Properties Win",
Description: "Let a table's own Iceberg properties override these settings and its maintenance configuration. Clear to make the maintenance configuration authoritative.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_BOOL,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TOGGLE,
},
{
Name: "where",
Label: "Where Filter",
Description: "Optional partition filter for compact, rewrite_position_delete_files, and rewrite_manifests. Supports field = literal, field IN (...), and AND.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
Placeholder: "region = 'us' AND dt IN ('2026-03-15')",
},
},
},
},
DefaultValues: map[string]*plugin_pb.ConfigValue{
"target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeMB}},
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
"delete_target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteTargetFileSizeMB}},
"delete_min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMinInputFiles}},
"delete_max_file_group_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMaxGroupSizeMB}},
"delete_max_output_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMaxOutputFiles}},
"min_manifests_to_rewrite": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinManifestsToRewrite}},
"snapshot_retention_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSnapshotRetentionHours}},
"max_snapshots_to_keep": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxSnapshotsToKeep}},
"orphan_older_than_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultOrphanOlderThanHours}},
"max_commit_retries": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxCommitRetries}},
"operations": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultOperations}},
"apply_deletes": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}},
"table_properties_override": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}},
"rewrite_strategy": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultRewriteStrategy}},
"sort_max_input_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
"sort_buffer_rows": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSortBufferRows}},
"sort_spill_dir": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
"where": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
},
},
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
Enabled: false, // disabled by default
DetectionIntervalMinutes: 60, // 1 hour
DetectionTimeoutSeconds: 300,
MaxJobsPerDetection: 100,
GlobalExecutionConcurrency: 4,
PerWorkerExecutionConcurrency: 2,
RetryLimit: 1,
RetryBackoffSeconds: 60,
JobTypeMaxRuntimeSeconds: 3600, // 1 hour max
ExecutionTimeoutSeconds: 3600,
},
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
"target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeMB}},
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
"delete_target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteTargetFileSizeMB}},
"delete_min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMinInputFiles}},
"delete_max_file_group_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMaxGroupSizeMB}},
"delete_max_output_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMaxOutputFiles}},
"snapshot_retention_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSnapshotRetentionHours}},
"max_snapshots_to_keep": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxSnapshotsToKeep}},
"orphan_older_than_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultOrphanOlderThanHours}},
"max_commit_retries": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxCommitRetries}},
"operations": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultOperations}},
"apply_deletes": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}},
"table_properties_override": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}},
"rewrite_strategy": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultRewriteStrategy}},
"sort_max_input_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
"sort_buffer_rows": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSortBufferRows}},
"sort_spill_dir": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
"where": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
},
}
}
func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionRequest, sender pluginworker.DetectionSender) error {
if request == nil {
return fmt.Errorf("run detection request is nil")
}
if sender == nil {
return fmt.Errorf("detection sender is nil")
}
if request.JobType != "" && request.JobType != jobType {
return fmt.Errorf("job type %q is not handled by iceberg maintenance handler", request.JobType)
}
workerConfig := ParseConfig(request.GetWorkerConfigValues())
ops, err := parseOperations(workerConfig.Operations)
if err != nil {
return fmt.Errorf("invalid operations config: %w", err)
}
if err := validateWhereOperations(workerConfig.Where, ops); err != nil {
return fmt.Errorf("invalid where config: %w", err)
}
// Detection interval is managed by the scheduler via AdminRuntimeDefaults.DetectionIntervalMinutes.
// ClusterContext.FilerAddresses are pb.ServerAddress strings
// (host:httpPort.grpcPort); collapse each to a dialable gRPC address.
filerGrpcAddresses := make([]string, 0)
if request.ClusterContext != nil {
for _, filer := range request.ClusterContext.FilerAddresses {
filerGrpcAddresses = append(filerGrpcAddresses, pb.ServerAddress(filer).ToGrpcAddress())
}
}
if len(filerGrpcAddresses) == 0 {
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("skipped", "no filer addresses in cluster context", nil))
return h.sendEmptyDetection(sender)
}
// Read scope filters
bucketFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "bucket_filter", ""))
namespaceFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "namespace_filter", ""))
tableFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "table_filter", ""))
resourceGroups, err := readResourceGroupConfig(request.GetAdminConfigValues())
if err != nil {
return fmt.Errorf("invalid admin resource group config: %w", err)
}
// Connect to filer — try each address until one succeeds.
filerGrpcAddress, conn, err := h.connectToFiler(ctx, filerGrpcAddresses)
if err != nil {
return fmt.Errorf("connect to filer: %w", err)
}
defer conn.Close()
filerClient := filer_pb.NewSeaweedFilerClient(conn)
maxResults := int(request.MaxResults)
scanLimit := maxResults
if resourceGroups.enabled() {
scanLimit = 0
}
tables, err := h.scanTablesForMaintenance(ctx, filerClient, workerConfig, bucketFilter, namespaceFilter, tableFilter, scanLimit)
if err != nil {
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("scan_error", fmt.Sprintf("error scanning tables: %v", err), nil))
return fmt.Errorf("scan tables: %w", err)
}
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("scan_complete",
fmt.Sprintf("found %d table(s) needing maintenance", len(tables)),
map[string]*plugin_pb.ConfigValue{
"tables_found": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(tables))}},
}))
tables, hasMore := selectTablesByResourceGroup(tables, resourceGroups, maxResults)
proposals := make([]*plugin_pb.JobProposal, 0, len(tables))
for _, t := range tables {
proposal := h.buildMaintenanceProposal(t, filerGrpcAddress, resourceGroupKey(t, resourceGroups.GroupBy))
proposals = append(proposals, proposal)
}
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
JobType: jobType,
Proposals: proposals,
HasMore: hasMore,
}); err != nil {
return err
}
return sender.SendComplete(&plugin_pb.DetectionComplete{
JobType: jobType,
Success: true,
TotalProposals: int32(len(proposals)),
})
}
func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender) error {
if request == nil || request.Job == nil {
return fmt.Errorf("execute request/job is nil")
}
if sender == nil {
return fmt.Errorf("execution sender is nil")
}
if request.Job.JobType != "" && request.Job.JobType != jobType {
return fmt.Errorf("job type %q is not handled by iceberg maintenance handler", request.Job.JobType)
}
canonicalJobType := request.Job.JobType
if canonicalJobType == "" {
canonicalJobType = jobType
}
params := request.Job.Parameters
bucketName := readStringConfig(params, "bucket_name", "")
namespace := readStringConfig(params, "namespace", "")
tableName := readStringConfig(params, "table_name", "")
tablePath := readStringConfig(params, "table_path", "")
filerGrpcAddress := readStringConfig(params, "filer_address", "")
if bucketName == "" || namespace == "" || tableName == "" || filerGrpcAddress == "" {
return fmt.Errorf("missing required parameters: bucket_name=%q, namespace=%q, table_name=%q, filer_address=%q", bucketName, namespace, tableName, filerGrpcAddress)
}
// Reject path traversal in bucket/namespace/table names.
for _, name := range []string{bucketName, namespace, tableName} {
if strings.Contains(name, "..") || strings.ContainsAny(name, "/\\") {
return fmt.Errorf("invalid name %q: must not contain path separators or '..'", name)
}
}
if tablePath == "" {
tablePath = path.Join(namespace, tableName)
}
// Sanitize tablePath to prevent directory traversal.
tablePath = path.Clean(tablePath)
expected := path.Join(namespace, tableName)
if tablePath != expected && !strings.HasPrefix(tablePath, expected+"/") {
return fmt.Errorf("invalid table_path %q: must be %q or a subpath", tablePath, expected)
}
workerConfig := ParseConfig(request.GetWorkerConfigValues())
ops, opsErr := parseOperations(workerConfig.Operations)
if opsErr != nil {
return fmt.Errorf("invalid operations config: %w", opsErr)
}
if err := validateWhereOperations(workerConfig.Where, ops); err != nil {
return fmt.Errorf("invalid where config: %w", err)
}
// Send initial progress
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: canonicalJobType,
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
ProgressPercent: 0,
Stage: "assigned",
Message: fmt.Sprintf("maintenance job accepted for %s/%s/%s", bucketName, namespace, tableName),
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("assigned", fmt.Sprintf("maintenance job accepted for %s/%s/%s", bucketName, namespace, tableName)),
},
}); err != nil {
return err
}
// Connect to filer
conn, err := h.dialFiler(ctx, filerGrpcAddress)
if err != nil {
return fmt.Errorf("connect to filer %s: %w", filerGrpcAddress, err)
}
defer conn.Close()
filerClient := filer_pb.NewSeaweedFilerClient(conn)
// Resolve once for the whole job: compaction commits new metadata as it
// runs, and a job should not change settings halfway through. Both reads
// fail the job rather than fall back, since without the configuration a
// disabled operation would run anyway, and without the properties the
// operations would rewrite the table to a size it did not ask for.
maintenance, err := loadMaintenanceConfiguration(ctx, filerClient, bucketName, tablePath)
if err != nil {
return fmt.Errorf("read maintenance configuration for %s/%s: %w", bucketName, tablePath, err)
}
ops = filterDisabledOperations(ops, maintenance)
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
if err != nil {
return fmt.Errorf("read table properties for %s/%s: %w", bucketName, tablePath, err)
}
workerConfig = resolveTableConfig(workerConfig, state.Metadata.Properties(), maintenance)
var results []string
if len(ops) == 0 {
results = append(results, "all maintenance operations are disabled for this table")
}
var lastErr error
totalOps := len(ops)
completedOps := 0
allMetrics := make(map[string]int64)
opErrors := make(map[string]error, totalOps)
// Execute operations in canonical maintenance order as defined by
// parseOperations.
for _, op := range ops {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
progress := float64(completedOps) / float64(totalOps) * 100
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: canonicalJobType,
State: plugin_pb.JobState_JOB_STATE_RUNNING,
ProgressPercent: progress,
Stage: op,
Message: fmt.Sprintf("running %s", op),
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity(op, fmt.Sprintf("starting %s for %s/%s/%s", op, bucketName, namespace, tableName)),
},
}); err != nil {
return err
}
var opResult string
var opErr error
var opMetrics map[string]int64
switch op {
case "compact":
opResult, opMetrics, opErr = h.compactDataFiles(ctx, filerClient, bucketName, tablePath, workerConfig, func(binIdx, totalBins int) {
binProgress := progress + float64(binIdx+1)/float64(totalBins)*(100.0/float64(totalOps))
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: canonicalJobType,
State: plugin_pb.JobState_JOB_STATE_RUNNING,
ProgressPercent: binProgress,
Stage: fmt.Sprintf("compact bin %d/%d", binIdx+1, totalBins),
Message: fmt.Sprintf("compacting bin %d of %d", binIdx+1, totalBins),
})
})
case "rewrite_position_delete_files":
opResult, opMetrics, opErr = h.rewritePositionDeleteFiles(ctx, filerClient, bucketName, tablePath, workerConfig)
case "expire_snapshots":
opResult, opMetrics, opErr = h.expireSnapshots(ctx, filerClient, bucketName, tablePath, workerConfig)
case "remove_orphans":
opResult, opMetrics, opErr = h.removeOrphans(ctx, filerClient, bucketName, tablePath, workerConfig)
case "rewrite_manifests":
opResult, opMetrics, opErr = h.rewriteManifests(ctx, filerClient, bucketName, tablePath, workerConfig)
default:
glog.Warningf("unknown maintenance operation: %s", op)
continue
}
// Accumulate per-operation metrics with dot-prefixed keys
for k, v := range opMetrics {
allMetrics[op+"."+k] = v
}
completedOps++
opErrors[op] = opErr
if opErr != nil {
glog.Warningf("iceberg maintenance %s failed for %s/%s/%s: %v", op, bucketName, namespace, tableName, opErr)
results = append(results, fmt.Sprintf("%s: error: %v", op, opErr))
lastErr = opErr
} else {
results = append(results, fmt.Sprintf("%s: %s", op, opResult))
}
}
recordJobStatus(ctx, filerClient, bucketName, tablePath, buildJobStatus(opErrors, time.Now().UTC()))
resultSummary := strings.Join(results, "; ")
success := lastErr == nil
// Build OutputValues with base table info + per-operation metrics
outputValues := map[string]*plugin_pb.ConfigValue{
"bucket": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: bucketName}},
"namespace": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: namespace}},
"table": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: tableName}},
}
for k, v := range allMetrics {
outputValues[k] = &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: v}}
}
return sender.SendCompleted(&plugin_pb.JobCompleted{
JobId: request.Job.JobId,
JobType: canonicalJobType,
Success: success,
ErrorMessage: func() string {
if lastErr != nil {
return lastErr.Error()
}
return ""
}(),
Result: &plugin_pb.JobResult{
Summary: resultSummary,
OutputValues: outputValues,
},
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("completed", resultSummary),
},
CompletedAt: timestamppb.Now(),
})
}
func (h *Handler) sendEmptyDetection(sender pluginworker.DetectionSender) error {
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
JobType: jobType,
Proposals: []*plugin_pb.JobProposal{},
HasMore: false,
}); err != nil {
return err
}
return sender.SendComplete(&plugin_pb.DetectionComplete{
JobType: jobType,
Success: true,
TotalProposals: 0,
})
}
// dialFiler connects to a filer at the given gRPC address. The address must
// already be a dialable host:grpcPort: Detect resolves it from
// ClusterContext.FilerAddresses via ToGrpcAddress and stores that resolved form
// in the job proposal parameter, so dialFiler dials it verbatim.
func (h *Handler) dialFiler(ctx context.Context, grpcAddress string) (*grpc.ClientConn, error) {
opCtx, opCancel := context.WithTimeout(ctx, filerConnectTimeout)
defer opCancel()
conn, err := pb.GrpcDial(opCtx, grpcAddress, false, h.grpcDialOption)
if err != nil {
return nil, err
}
client := filer_pb.NewSeaweedFilerClient(conn)
if _, err := client.Ping(opCtx, &filer_pb.PingRequest{}); err != nil {
_ = conn.Close()
return nil, err
}
return conn, nil
}
// connectToFiler tries each filer gRPC address in order and returns the
// first address whose gRPC connection and Ping request succeed.
func (h *Handler) connectToFiler(ctx context.Context, grpcAddresses []string) (string, *grpc.ClientConn, error) {
var lastErr error
for _, grpcAddr := range grpcAddresses {
conn, err := h.dialFiler(ctx, grpcAddr)
if err != nil {
lastErr = fmt.Errorf("filer %s: %w", grpcAddr, err)
continue
}
return grpcAddr, conn, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no filer addresses provided")
}
return "", nil, lastErr
}
// Ensure Handler implements JobHandler.
var _ pluginworker.JobHandler = (*Handler)(nil)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
package iceberg
import (
"context"
"encoding/json"
"path"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// jobStatusUpdateAttempts bounds the read-modify-write retry loop for the job
// status attribute. Asserting the whole attribute set makes a lost race more
// likely, so allow a couple of retries before giving up.
const jobStatusUpdateAttempts = 3
// buildJobStatus folds the operations this job ran into the AWS job types that
// govern them. Operations that did not run are left out so a partial run does
// not erase what an earlier one recorded.
func buildJobStatus(opErrors map[string]error, ranAt time.Time) s3tables.MaintenanceJobStatus {
jobStatus := s3tables.MaintenanceJobStatus{}
for op, opErr := range opErrors {
jobType := maintenanceJobType(op)
if jobType == "" {
continue
}
value, ok := jobStatus[jobType]
if !ok {
timestamp := ranAt
value = &s3tables.MaintenanceJobStatusValue{
Status: s3tables.MaintenanceJobStatusSuccessful,
LastRunTimestamp: &timestamp,
}
jobStatus[jobType] = value
}
// Several operations can map to one job type; one failure fails the type.
if opErr != nil && value.Status != s3tables.MaintenanceJobStatusFailed {
value.Status = s3tables.MaintenanceJobStatusFailed
value.FailureMessage = opErr.Error()
}
}
return jobStatus
}
// recordJobStatus merges this job's outcome into the table's stored status.
// Status is advisory, so a lost race is logged rather than failing a job whose
// work already committed.
func recordJobStatus(
ctx context.Context,
client filer_pb.SeaweedFilerClient,
bucketName, tablePath string,
jobStatus s3tables.MaintenanceJobStatus,
) {
if len(jobStatus) == 0 {
return
}
tableDir := path.Join(s3tables.TablesPath, bucketName, tablePath)
dir, name := path.Dir(tableDir), path.Base(tableDir)
for attempt := 0; attempt < jobStatusUpdateAttempts; attempt++ {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
glog.V(1).Infof("iceberg maintenance: cannot record job status for %s/%s: %v", bucketName, tablePath, err)
return
}
if resp == nil || resp.Entry == nil {
return
}
entry := resp.Entry
current := entry.Extended[s3tables.ExtendedKeyMaintenanceStatus]
// Assert every attribute, not just our own: UpdateEntry rewrites the
// whole entry, so a narrow assertion would let this write revert a
// maintenance configuration an operator just disabled.
expected := s3tables.SnapshotExtended(entry.Extended, s3tables.ExtendedKeyMaintenanceStatus)
merged := s3tables.MaintenanceJobStatus{}
if len(current) > 0 {
if err := json.Unmarshal(current, &merged); err != nil {
glog.V(1).Infof("iceberg maintenance: replacing unreadable job status on %s/%s: %v", bucketName, tablePath, err)
merged = s3tables.MaintenanceJobStatus{}
}
}
for jobType, value := range jobStatus {
merged[jobType] = value
}
data, err := json.Marshal(merged)
if err != nil {
glog.V(1).Infof("iceberg maintenance: cannot marshal job status for %s/%s: %v", bucketName, tablePath, err)
return
}
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
entry.Extended[s3tables.ExtendedKeyMaintenanceStatus] = data
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: dir,
Entry: entry,
ExpectedExtended: expected,
})
if err == nil {
return
}
if status.Code(err) != codes.FailedPrecondition {
glog.V(1).Infof("iceberg maintenance: cannot record job status for %s/%s: %v", bucketName, tablePath, err)
return
}
}
glog.V(1).Infof("iceberg maintenance: gave up recording job status for %s/%s after %d attempts", bucketName, tablePath, jobStatusUpdateAttempts)
}

View File

@@ -0,0 +1,734 @@
package iceberg
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math/rand/v2"
"path"
"sort"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// errStalePlan is returned by a commit mutation when the table head has
// advanced since planning. The caller should not retry the same plan.
var errStalePlan = errors.New("stale plan: table head changed since planning")
// errMetadataVersionConflict is returned when the xattr update detects a
// concurrent metadata version change (compare-and-swap failure).
var errMetadataVersionConflict = errors.New("metadata version conflict")
// ---------------------------------------------------------------------------
// Operation: Expire Snapshots
// ---------------------------------------------------------------------------
// expireSnapshots removes old snapshots from the table metadata and cleans up
// their manifest list files.
func (h *Handler) expireSnapshots(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, tablePath string,
config Config,
) (string, map[string]int64, error) {
start := time.Now()
// Load current metadata
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
if err != nil {
return "", nil, fmt.Errorf("load metadata: %w", err)
}
meta, dataPath := state.Metadata, state.DataPath
snapshots := meta.Snapshots()
if len(snapshots) == 0 {
return "no snapshots", nil, nil
}
currentSnap := meta.CurrentSnapshot()
var currentSnapID int64
if currentSnap != nil {
currentSnapID = currentSnap.SnapshotID
}
toExpire := snapshotsToExpire(meta, config, time.Now().UnixMilli())
if len(toExpire) == 0 {
return "no snapshots expired", nil, nil
}
// Split snapshots into expired and kept sets
expireSet := make(map[int64]struct{}, len(toExpire))
for _, id := range toExpire {
expireSet[id] = struct{}{}
}
var expiredSnaps, keptSnaps []table.Snapshot
for _, snap := range snapshots {
if _, ok := expireSet[snap.SnapshotID]; ok {
expiredSnaps = append(expiredSnaps, snap)
} else {
keptSnaps = append(keptSnaps, snap)
}
}
// Collect all files referenced by each set before modifying metadata.
// This lets us determine which files become unreferenced.
expiredFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, dataPath, expiredSnaps)
if err != nil {
return "", nil, fmt.Errorf("collect expired snapshot files: %w", err)
}
keptFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, dataPath, keptSnaps)
if err != nil {
return "", nil, fmt.Errorf("collect kept snapshot files: %w", err)
}
// Resolve kept file paths for consistent comparison
keptFilerPaths := make(map[string]struct{}, len(keptFiles))
for f := range keptFiles {
if filerPath, err := icebergFilerPath(bucketName, dataPath, f); err == nil {
keptFilerPaths[filerPath] = struct{}{}
}
}
// Use MetadataBuilder to remove snapshots and create new metadata
err = h.commitWithRetry(ctx, filerClient, bucketName, tablePath, state.MetadataFileName, config, func(currentMeta table.Metadata, builder *table.MetadataBuilder) error {
// Guard: verify table head hasn't changed since we planned
cs := currentMeta.CurrentSnapshot()
if (cs == nil) != (currentSnapID == 0) || (cs != nil && cs.SnapshotID != currentSnapID) {
return errStalePlan
}
// A tag or branch created since planning can pin a snapshot this plan
// expires without moving the head, and RemoveSnapshots would drop that
// ref along with it. Re-plan instead.
nowProtected := protectedSnapshots(currentMeta, time.Now().UnixMilli())
for _, id := range toExpire {
if _, pinned := nowProtected[id]; pinned {
return errStalePlan
}
}
return builder.RemoveSnapshots(toExpire, false)
})
if err != nil {
return "", nil, fmt.Errorf("commit snapshot expiration: %w", err)
}
// Delete files exclusively referenced by expired snapshots (best-effort)
deletedCount := 0
for filePath := range expiredFiles {
resolved, err := icebergFilerPath(bucketName, dataPath, filePath)
if err != nil {
glog.Warningf("iceberg maintenance: cannot resolve expired file %s: %v", filePath, err)
continue
}
if _, stillReferenced := keptFilerPaths[resolved]; stillReferenced {
continue
}
if delErr := deleteFilerFile(ctx, filerClient, path.Dir(resolved), path.Base(resolved)); delErr != nil {
glog.Warningf("iceberg maintenance: failed to delete unreferenced file %s: %v", filePath, delErr)
} else {
deletedCount++
}
}
metrics := map[string]int64{
MetricSnapshotsExpired: int64(len(toExpire)),
MetricFilesDeleted: int64(deletedCount),
MetricDurationMs: time.Since(start).Milliseconds(),
}
return fmt.Sprintf("expired %d snapshot(s), deleted %d unreferenced file(s)", len(toExpire), deletedCount), metrics, nil
}
// snapshotsToExpire returns the snapshot IDs that fall outside the configured
// retention. Detection and execution both call it so a table is only proposed
// for expiry when the run would actually remove something.
func snapshotsToExpire(meta table.Metadata, config Config, nowMs int64) []int64 {
var currentSnapID int64
if currentSnap := meta.CurrentSnapshot(); currentSnap != nil {
currentSnapID = currentSnap.SnapshotID
}
retentionMs := config.SnapshotRetentionMs
protected := protectedSnapshots(meta, nowMs)
// Sort snapshots by timestamp descending (most recent first) so that
// the keep-count logic always preserves the newest snapshots.
sorted := make([]table.Snapshot, len(meta.Snapshots()))
copy(sorted, meta.Snapshots())
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].TimestampMs > sorted[j].TimestampMs
})
// Walk from newest to oldest. The current snapshot is always kept.
// Among the remaining, keep up to MaxSnapshotsToKeep-1 (since current
// counts toward the quota). Expire the rest only if they exceed the
// retention window; snapshots within the window are kept regardless.
var toExpire []int64
var kept int64
for _, snap := range sorted {
if snap.SnapshotID == currentSnapID {
kept++
continue
}
if _, isProtected := protected[snap.SnapshotID]; isProtected {
kept++
continue
}
if kept < config.MaxSnapshotsToKeep {
kept++
continue
}
if nowMs-snap.TimestampMs > retentionMs {
toExpire = append(toExpire, snap.SnapshotID)
} else {
kept++
}
}
return toExpire
}
// protectedSnapshots returns the snapshots that named refs hold in place.
// A branch head or a tag pins its snapshot no matter how old it is, and a
// branch may carry its own retention overrides for the ancestors behind it.
// iceberg-go's RemoveSnapshots drops any ref whose snapshot is gone without
// complaint, so expiring one of these would silently delete the tag and then
// the files it pointed at.
func protectedSnapshots(meta table.Metadata, nowMs int64) map[int64]struct{} {
protected := make(map[int64]struct{})
byID := make(map[int64]table.Snapshot)
for _, snap := range meta.Snapshots() {
byID[snap.SnapshotID] = snap
}
for _, ref := range meta.Refs() {
protected[ref.SnapshotID] = struct{}{}
if ref.SnapshotRefType != table.BranchRef {
continue
}
// Without overrides the branch keeps only its head here; the ancestors
// behind it stay under the worker's own retention config.
if ref.MinSnapshotsToKeep == nil && ref.MaxSnapshotAgeMs == nil {
continue
}
minToKeep := 1
if ref.MinSnapshotsToKeep != nil {
minToKeep = *ref.MinSnapshotsToKeep
}
seen := make(map[int64]struct{})
kept := 0
for id := ref.SnapshotID; ; {
if _, looped := seen[id]; looped {
break
}
seen[id] = struct{}{}
snap, ok := byID[id]
if !ok {
break
}
withinAge := ref.MaxSnapshotAgeMs != nil && nowMs-snap.TimestampMs <= *ref.MaxSnapshotAgeMs
if kept >= minToKeep && !withinAge {
break
}
protected[snap.SnapshotID] = struct{}{}
kept++
if snap.ParentSnapshotID == nil {
break
}
id = *snap.ParentSnapshotID
}
}
return protected
}
// collectSnapshotFiles returns all file paths (manifest lists, manifest files,
// data files) referenced by the given snapshots. It returns an error if any
// manifest list or manifest cannot be read/parsed, to prevent delete decisions
// based on incomplete reference data.
func collectSnapshotFiles(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, dataPath string,
snapshots []table.Snapshot,
) (map[string]struct{}, error) {
files := make(map[string]struct{})
for _, snap := range snapshots {
if snap.ManifestList == "" {
continue
}
files[snap.ManifestList] = struct{}{}
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, snap.ManifestList)
if err != nil {
return nil, fmt.Errorf("read manifest list %s: %w", snap.ManifestList, err)
}
manifests, err := s3tables.ReadManifestList(manifestListData)
if err != nil {
return nil, fmt.Errorf("parse manifest list %s: %w", snap.ManifestList, err)
}
for _, mf := range manifests {
files[mf.FilePath()] = struct{}{}
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, mf.FilePath())
if err != nil {
return nil, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
}
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), false)
if err != nil {
return nil, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
}
for _, entry := range entries {
files[entry.DataFile().FilePath()] = struct{}{}
}
}
}
return files, nil
}
// ---------------------------------------------------------------------------
// Operation: Remove Orphans
// ---------------------------------------------------------------------------
// removeOrphans finds and deletes unreferenced files from the table's
// metadata/ and data/ directories.
func (h *Handler) removeOrphans(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, tablePath string,
config Config,
) (string, map[string]int64, error) {
start := time.Now()
// Load current metadata
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
if err != nil {
return "", nil, fmt.Errorf("load metadata: %w", err)
}
orphanCandidates, err := collectOrphanCandidates(ctx, filerClient, bucketName, state.DataPath, state.Metadata, state.MetadataFileName, config.OrphanOlderThanHours)
if err != nil {
return "", nil, fmt.Errorf("collect orphan candidates: %w", err)
}
orphanCount := 0
for _, candidate := range orphanCandidates {
if delErr := deleteFilerFile(ctx, filerClient, candidate.Dir, candidate.Entry.Name); delErr != nil {
glog.Warningf("iceberg maintenance: failed to delete orphan %s/%s: %v", candidate.Dir, candidate.Entry.Name, delErr)
} else {
orphanCount++
}
}
metrics := map[string]int64{
MetricOrphansRemoved: int64(orphanCount),
MetricDurationMs: time.Since(start).Milliseconds(),
}
return fmt.Sprintf("removed %d orphan file(s)", orphanCount), metrics, nil
}
func collectOrphanCandidates(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, dataPath string,
meta table.Metadata,
metadataFileName string,
orphanOlderThanHours int64,
) ([]filerFileEntry, error) {
referencedFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, dataPath, meta.Snapshots())
if err != nil {
return nil, fmt.Errorf("collect referenced files: %w", err)
}
referencedFiles[path.Join("metadata", metadataFileName)] = struct{}{}
for mle := range meta.PreviousFiles() {
referencedFiles[mle.MetadataFile] = struct{}{}
}
referencedFilerPaths := make(map[string]struct{}, len(referencedFiles))
for ref := range referencedFiles {
if filerPath, err := icebergFilerPath(bucketName, dataPath, ref); err == nil {
referencedFilerPaths[filerPath] = struct{}{}
}
}
tableBasePath := path.Join(s3tables.TablesPath, bucketName, dataPath)
safetyThreshold := time.Now().Add(-time.Duration(orphanOlderThanHours) * time.Hour)
var candidates []filerFileEntry
for _, subdir := range []string{"metadata", "data"} {
dirPath := path.Join(tableBasePath, subdir)
fileEntries, err := walkFilerEntries(ctx, filerClient, dirPath)
if err != nil {
glog.V(2).Infof("iceberg maintenance: cannot walk %s: %v", dirPath, err)
continue
}
for _, fe := range fileEntries {
entry := fe.Entry
if _, isReferenced := referencedFilerPaths[path.Join(fe.Dir, entry.Name)]; isReferenced {
continue
}
if entry.Attributes == nil {
continue
}
if time.Unix(entry.Attributes.Mtime, 0).After(safetyThreshold) {
continue
}
candidates = append(candidates, fe)
}
}
return candidates, nil
}
// ---------------------------------------------------------------------------
// Operation: Rewrite Manifests
// ---------------------------------------------------------------------------
// rewriteManifests merges small manifests into fewer, larger ones.
func (h *Handler) rewriteManifests(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, tablePath string,
config Config,
) (string, map[string]int64, error) {
start := time.Now()
// Load current metadata
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
if err != nil {
return "", nil, fmt.Errorf("load metadata: %w", err)
}
meta, dataPath := state.Metadata, state.DataPath
predicate, err := parsePartitionPredicate(config.Where, meta)
if err != nil {
return "", nil, err
}
currentSnap := meta.CurrentSnapshot()
if currentSnap == nil || currentSnap.ManifestList == "" {
return "no current snapshot", nil, nil
}
// Read manifest list
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, currentSnap.ManifestList)
if err != nil {
return "", nil, fmt.Errorf("read manifest list: %w", err)
}
manifests, err := s3tables.ReadManifestList(manifestListData)
if err != nil {
return "", nil, fmt.Errorf("parse manifest list: %w", err)
}
// Separate data manifests from delete manifests. Only data manifests
// are candidates for rewriting; delete manifests are carried forward.
var dataManifests []iceberg.ManifestFile
for _, mf := range manifests {
if mf.ManifestContent() == iceberg.ManifestContentData {
dataManifests = append(dataManifests, mf)
}
}
// Collect all entries from data manifests, grouped by partition spec ID
// so we write one merged manifest per spec (required for spec-evolved tables).
type specEntries struct {
specID int32
spec iceberg.PartitionSpec
entries []iceberg.ManifestEntry
}
specMap := make(map[int32]*specEntries)
// Build a lookup from spec ID to PartitionSpec
specByID := specByID(meta)
schema := meta.CurrentSchema()
var carriedDataManifests []iceberg.ManifestFile
var manifestsRewritten int64
for _, mf := range dataManifests {
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, mf.FilePath())
if err != nil {
return "", nil, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
}
entries, err := s3tables.ReadManifest(mf, manifestData, true, specByID, schema)
if err != nil {
return "", nil, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
}
sid := mf.PartitionSpecID()
spec, found := specByID[int(sid)]
if !found {
return "", nil, fmt.Errorf("partition spec %d not found in table metadata", sid)
}
if predicate != nil {
allMatch := len(entries) > 0
for _, entry := range entries {
match, err := predicate.Matches(spec, entry.DataFile().Partition())
if err != nil {
return "", nil, err
}
if !match {
allMatch = false
break
}
}
if !allMatch {
carriedDataManifests = append(carriedDataManifests, mf)
continue
}
}
se, ok := specMap[sid]
if !ok {
se = &specEntries{specID: sid, spec: spec}
specMap[sid] = se
}
se.entries = append(se.entries, entries...)
manifestsRewritten++
}
if manifestsRewritten < config.MinManifestsToRewrite {
return fmt.Sprintf("only %d data manifests, below threshold of %d", manifestsRewritten, config.MinManifestsToRewrite), nil, nil
}
if len(specMap) == 0 {
return "no data entries to rewrite", nil, nil
}
version := meta.Version()
snapshotID := currentSnap.SnapshotID
newSnapshotID := time.Now().UnixMilli()
newSeqNum := currentSnap.SequenceNumber + 1
artifactSuffix := compactRandomSuffix()
metaDir := path.Join(s3tables.TablesPath, bucketName, dataPath, "metadata")
// Track written artifacts so we can clean them up if the commit fails.
type artifact struct {
dir, fileName string
}
var writtenArtifacts []artifact
committed := false
defer func() {
if committed || len(writtenArtifacts) == 0 {
return
}
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, a := range writtenArtifacts {
if err := deleteFilerFile(cleanupCtx, filerClient, a.dir, a.fileName); err != nil {
glog.Warningf("iceberg rewrite-manifests: failed to clean up artifact %s/%s: %v", a.dir, a.fileName, err)
}
}
}()
// Write one merged manifest per partition spec
var newManifests []iceberg.ManifestFile
newManifests = append(newManifests, carriedDataManifests...)
totalEntries := 0
for _, se := range specMap {
totalEntries += len(se.entries)
manifestFileName := fmt.Sprintf("merged-%d-%s-spec%d.avro", newSnapshotID, artifactSuffix, se.specID)
manifestPath := absoluteIcebergPath(bucketName, dataPath, "metadata", manifestFileName)
var manifestBuf bytes.Buffer
mergedManifest, err := iceberg.WriteManifest(
manifestPath,
&manifestBuf,
version,
se.spec,
schema,
newSnapshotID,
se.entries,
)
if err != nil {
return "", nil, fmt.Errorf("write merged manifest for spec %d: %w", se.specID, err)
}
if err := saveFilerFile(ctx, filerClient, metaDir, manifestFileName, manifestBuf.Bytes()); err != nil {
return "", nil, fmt.Errorf("save merged manifest for spec %d: %w", se.specID, err)
}
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestFileName})
newManifests = append(newManifests, mergedManifest)
}
// Include any delete manifests that were not rewritten
for _, mf := range manifests {
if mf.ManifestContent() != iceberg.ManifestContentData {
newManifests = append(newManifests, mf)
}
}
var manifestListBuf bytes.Buffer
err = iceberg.WriteManifestList(version, &manifestListBuf, newSnapshotID, &snapshotID, &newSeqNum, 0, newManifests)
if err != nil {
return "", nil, fmt.Errorf("write manifest list: %w", err)
}
// Save new manifest list
manifestListFileName := fmt.Sprintf("snap-%d-%s.avro", newSnapshotID, artifactSuffix)
if err := saveFilerFile(ctx, filerClient, metaDir, manifestListFileName, manifestListBuf.Bytes()); err != nil {
return "", nil, fmt.Errorf("save manifest list: %w", err)
}
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestListFileName})
// Create new snapshot with the rewritten manifest list
manifestListLocation := absoluteIcebergPath(bucketName, dataPath, "metadata", manifestListFileName)
err = h.commitWithRetry(ctx, filerClient, bucketName, tablePath, state.MetadataFileName, config, func(currentMeta table.Metadata, builder *table.MetadataBuilder) error {
// Guard: verify table head hasn't advanced since we planned.
// The merged manifest and manifest list were built against snapshotID;
// if the head moved, they reference stale state.
cs := currentMeta.CurrentSnapshot()
if cs == nil || cs.SnapshotID != snapshotID {
return errStalePlan
}
newSnapshot := &table.Snapshot{
SnapshotID: newSnapshotID,
ParentSnapshotID: &snapshotID,
SequenceNumber: cs.SequenceNumber + 1,
TimestampMs: time.Now().UnixMilli(),
ManifestList: manifestListLocation,
// Merging manifests changes no file, so the totals carry over.
Summary: snapshotSummary{}.build(table.OpReplace, cs, map[string]string{"maintenance": "rewrite_manifests"}),
SchemaID: func() *int {
id := schema.ID
return &id
}(),
}
if err := builder.AddSnapshot(newSnapshot); err != nil {
return err
}
return builder.SetSnapshotRef(
table.MainBranch,
newSnapshotID,
table.BranchRef,
)
})
if err != nil {
return "", nil, fmt.Errorf("commit manifest rewrite: %w", err)
}
committed = true
metrics := map[string]int64{
MetricManifestsRewritten: manifestsRewritten,
MetricEntriesTotal: int64(totalEntries),
MetricDurationMs: time.Since(start).Milliseconds(),
}
return fmt.Sprintf("rewrote %d manifests into %d (%d entries)", manifestsRewritten, len(specMap), totalEntries), metrics, nil
}
// ---------------------------------------------------------------------------
// Commit Protocol with Retry
// ---------------------------------------------------------------------------
// commitWithRetry implements optimistic concurrency for metadata updates.
// It reads the current metadata, applies the mutation, writes a new metadata
// file, and updates the table entry. On version conflict, it retries.
func (h *Handler) commitWithRetry(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, tablePath, currentMetadataFileName string,
config Config,
mutate func(currentMeta table.Metadata, builder *table.MetadataBuilder) error,
) error {
maxRetries := config.MaxCommitRetries
if maxRetries <= 0 || maxRetries > 20 {
maxRetries = defaultMaxCommitRetries
}
for attempt := int64(0); attempt < maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(50*(1<<(attempt-1))) * time.Millisecond // exponential: 50ms, 100ms, 200ms, ...
const maxBackoff = 5 * time.Second
if backoff > maxBackoff {
backoff = maxBackoff
}
jitter := time.Duration(rand.Int64N(int64(backoff) / 5)) // 0–20% of backoff
timer := time.NewTimer(backoff + jitter)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return ctx.Err()
}
}
// Load current metadata
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
if err != nil {
return fmt.Errorf("load metadata (attempt %d): %w", attempt, err)
}
meta, metaFileName, dataPath := state.Metadata, state.MetadataFileName, state.DataPath
// Build new metadata — pass the current metadata file location so the
// metadata log correctly records where the previous version lives.
currentMetaFilePath := absoluteIcebergPath(bucketName, dataPath, "metadata", metaFileName)
builder, err := table.MetadataBuilderFromBase(meta, currentMetaFilePath)
if err != nil {
return fmt.Errorf("create metadata builder (attempt %d): %w", attempt, err)
}
// Apply the mutation
if err := mutate(meta, builder); err != nil {
return fmt.Errorf("apply mutation (attempt %d): %w", attempt, err)
}
if !builder.HasChanges() {
return nil // nothing to commit
}
newMeta, err := builder.Build()
if err != nil {
return fmt.Errorf("build metadata (attempt %d): %w", attempt, err)
}
// Serialize
metadataBytes, err := json.Marshal(newMeta)
if err != nil {
return fmt.Errorf("marshal metadata (attempt %d): %w", attempt, err)
}
// Determine new metadata file name. Include a timestamp suffix so
// concurrent writers stage to distinct files instead of clobbering.
currentVersion := extractMetadataVersion(metaFileName)
newVersion := currentVersion + 1
newMetadataFileName := fmt.Sprintf("v%d-%d.metadata.json", newVersion, time.Now().UnixNano())
// Save new metadata file
metaDir := path.Join(s3tables.TablesPath, bucketName, dataPath, "metadata")
if err := saveFilerFile(ctx, filerClient, metaDir, newMetadataFileName, metadataBytes); err != nil {
return fmt.Errorf("save metadata file (attempt %d): %w", attempt, err)
}
// Update the table entry's xattr with new metadata (CAS on version)
tableDir := path.Join(s3tables.TablesPath, bucketName, tablePath)
newMetadataLocation := absoluteIcebergPath(bucketName, dataPath, "metadata", newMetadataFileName)
err = updateTableMetadataXattr(ctx, filerClient, tableDir, currentVersion, metadataBytes, newMetadataLocation)
if err != nil {
// Use a detached context for cleanup so staged files are removed
// even if the original context was canceled.
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
if !errors.Is(err, errMetadataVersionConflict) {
// Non-conflict error (permissions, transport, etc.): fail immediately.
_ = deleteFilerFile(cleanupCtx, filerClient, metaDir, newMetadataFileName)
cleanupCancel()
return fmt.Errorf("update table xattr (attempt %d): %w", attempt, err)
}
// Version conflict: clean up the new metadata file and retry
_ = deleteFilerFile(cleanupCtx, filerClient, metaDir, newMetadataFileName)
cleanupCancel()
if attempt < maxRetries-1 {
glog.V(1).Infof("iceberg maintenance: version conflict on %s/%s, retrying (attempt %d)", bucketName, tablePath, attempt)
continue
}
return fmt.Errorf("update table xattr (attempt %d): %w", attempt, err)
}
return nil
}
return fmt.Errorf("exceeded max commit retries (%d)", maxRetries)
}

View File

@@ -0,0 +1,152 @@
package iceberg
import (
"bytes"
"context"
"fmt"
"path"
"testing"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
)
// Merging manifests a foreign producer wrote used to fail for good on a
// day-partitioned table, whichever way round the producer spelled the
// partition union: nothing between reading an entry and writing it looks at
// its partition, so the time.Time the Avro decoder produced is still there
// when the manifest writer, which has no date logical type for a day
// partition, tries to encode it.
func TestRewriteManifestsNormalizesForeignDayPartitions(t *testing.T) {
for _, valueFirst := range []bool{true, false} {
name := "null-first partition union"
if valueFirst {
name = "value-first partition union"
}
t.Run(name, func(t *testing.T) {
rewriteForeignDayPartitions(t, valueFirst)
})
}
}
func rewriteForeignDayPartitions(t *testing.T, valueFirst bool) {
t.Helper()
fs, client := startFakeFiler(t)
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "event_time", Type: iceberg.PrimitiveTypes.Timestamp, Required: true},
)
spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
SourceIDs: []int{1}, FieldID: 1000, Name: "event_time_day", Transform: iceberg.DayTransform{},
})
setup := tableSetup{
BucketName: "test-bucket",
Namespace: "analytics",
TableName: "events",
Schema: schema,
Spec: &spec,
Snapshots: []table.Snapshot{{
SnapshotID: 1,
TimestampMs: time.Now().UnixMilli(),
ManifestList: "metadata/snap-1.avro",
}},
}
meta := populateTable(t, fs, setup)
metaDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "metadata")
wantDays := make(map[string]iceberg.Date)
var manifests []iceberg.ManifestFile
for i := 0; i < 3; i++ {
day := iceberg.Date(20737 + i)
filePath := setup.fileRef("data", fmt.Sprintf("foreign-%d.parquet", i))
dfBuilder, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentData, filePath, iceberg.ParquetFile,
map[int]any{1000: day}, nil, nil, 1, 1)
if err != nil {
t.Fatalf("build data file %d: %v", i, err)
}
snapshotID := int64(1)
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapshotID, nil, nil, dfBuilder.Build())
manifestName := fmt.Sprintf("foreign-manifest-%d.avro", i)
foreignBytes, manifest := s3tablestest.ForeignPartitionManifest(t, schema, spec, entry,
setup.fileRef("metadata", manifestName), "date", time.Unix(int64(day)*24*60*60, 0).UTC(), valueFirst)
fs.putEntry(metaDir, manifestName, &filer_pb.Entry{
Name: manifestName, Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()}, Content: foreignBytes,
})
manifests = append(manifests, manifest)
wantDays[filePath] = day
}
var manifestList bytes.Buffer
seqNum := int64(1)
if err := iceberg.WriteManifestList(meta.Version(), &manifestList, 1, nil, &seqNum, 0, manifests); err != nil {
t.Fatalf("write manifest list: %v", err)
}
fs.putEntry(metaDir, "snap-1.avro", &filer_pb.Entry{
Name: "snap-1.avro", Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()}, Content: manifestList.Bytes(),
})
result, _, err := NewHandler(nil).rewriteManifests(context.Background(), client, setup.BucketName, setup.tablePath(), Config{
MinManifestsToRewrite: 3,
MaxCommitRetries: 3,
})
if err != nil {
t.Fatalf("rewriteManifests failed for foreign day partitions: %v", err)
}
if result != "rewrote 3 manifests into 1 (3 entries)" {
t.Fatalf("rewriteManifests result = %q, want 3 manifests merged", result)
}
// The merged manifest has to carry the days the foreign manifests held.
// iceberg-go writes a day partition as a bare Avro int, without the date
// logical type, but now converts day-transform partitions to iceberg.Date
// on read (applyDayTransformDates), so they read back as iceberg.Date.
got := make(map[string]any)
for _, mf := range currentManifests(t, client, setup) {
manifestData, err := loadFileByIcebergPath(context.Background(), client, setup.BucketName, setup.tablePath(), mf.FilePath())
if err != nil {
t.Fatalf("load merged manifest %s: %v", mf.FilePath(), err)
}
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
if err != nil {
t.Fatalf("parse merged manifest %s: %v", mf.FilePath(), err)
}
for _, entry := range entries {
got[entry.DataFile().FilePath()] = entry.DataFile().Partition()[1000]
}
}
if len(got) != len(wantDays) {
t.Fatalf("merged manifests hold %d entries, want %d", len(got), len(wantDays))
}
for filePath, day := range wantDays {
if got[filePath] != day {
t.Errorf("%s partition = %#v (%T), want %d", filePath, got[filePath], got[filePath], day)
}
}
}
// currentManifests reads the manifests of the table's current snapshot.
func currentManifests(t *testing.T, client filer_pb.SeaweedFilerClient, setup tableSetup) []iceberg.ManifestFile {
t.Helper()
state, err := loadCurrentMetadata(context.Background(), client, setup.BucketName, setup.tablePath())
if err != nil {
t.Fatalf("reload metadata: %v", err)
}
snapshot := state.Metadata.CurrentSnapshot()
if snapshot == nil {
t.Fatal("table has no current snapshot")
}
manifestListData, err := loadFileByIcebergPath(context.Background(), client, setup.BucketName, setup.tablePath(), snapshot.ManifestList)
if err != nil {
t.Fatalf("load manifest list: %v", err)
}
manifests, err := s3tables.ReadManifestList(manifestListData)
if err != nil {
t.Fatalf("parse manifest list: %v", err)
}
return manifests
}

View File

@@ -0,0 +1,307 @@
package iceberg
import (
"context"
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type planningIndex struct {
SnapshotID int64 `json:"snapshotId"`
ManifestList string `json:"manifestList,omitempty"`
UpdatedAtMs int64 `json:"updatedAtMs"`
DataManifestCount int64 `json:"dataManifestCount,omitempty"`
Compaction *planningIndexCompaction `json:"compaction,omitempty"`
RewriteManifests *planningIndexRewriteManifests `json:"rewriteManifests,omitempty"`
}
type planningIndexCompaction struct {
ConfigHash string `json:"configHash"`
Eligible bool `json:"eligible"`
}
type planningIndexRewriteManifests struct {
Threshold int64 `json:"threshold"`
Eligible bool `json:"eligible"`
}
type tableMetadataEnvelope struct {
// Format is the catalog's own record of what the table is. A native Lance
// table has no Iceberg metadata at all, so without this the parse below
// fails and the table is skipped as if it were corrupt.
Format string `json:"format"`
MetadataVersion int `json:"metadataVersion"`
MetadataLocation string `json:"metadataLocation,omitempty"`
Metadata *struct {
FullMetadata json.RawMessage `json:"fullMetadata,omitempty"`
} `json:"metadata,omitempty"`
PlanningIndex json.RawMessage `json:"planningIndex,omitempty"`
}
// tableState is the catalog's view of one table: its Iceberg metadata, the
// metadata file backing it, where its files live, and the cached planning index.
type tableState struct {
Metadata table.Metadata
MetadataFileName string
// DataPath is the bucket-relative directory holding metadata/ and data/.
DataPath string
PlanningIndex *planningIndex
}
// tableTypeProperty is the Hive/Glue-style format marker. Catalogs that have no
// native concept of a non-Iceberg table register one as an Iceberg table with a
// placeholder schema and set this property instead; the Lance namespace's
// Iceberg REST adapter writes table_type=lance.
const tableTypeProperty = "table_type"
// errForeignFormat means the catalog entry belongs to a format this worker does
// not maintain. It is a normal outcome of scanning a mixed catalog, not damage.
var errForeignFormat = errors.New("not an iceberg table")
// isIcebergTableEntry reports whether a catalog entry is an Iceberg table this
// worker may rewrite. Views share the entry shape, and a foreign format
// registered through the catalog shares the location but not the file layout -
// a Lance dataset keeps its fragments under data/, where every one of them is
// unreferenced by the Iceberg metadata and so looks like an orphan.
func isIcebergTableEntry(extended map[string][]byte, meta table.Metadata) bool {
if s3tables.EntryType(extended) != s3tables.EntryTypeTable {
return false
}
tableType, ok := meta.Properties()[tableTypeProperty]
return !ok || strings.EqualFold(tableType, "iceberg")
}
func parseTableMetadataEnvelope(metadataBytes []byte, bucketName, tablePath string) (*tableState, error) {
var envelope tableMetadataEnvelope
if err := json.Unmarshal(metadataBytes, &envelope); err != nil {
return nil, fmt.Errorf("parse metadata xattr: %w", err)
}
if envelope.Format != "" && envelope.Format != s3tables.FormatIceberg {
return nil, fmt.Errorf("%w: %s", errForeignFormat, envelope.Format)
}
if envelope.Metadata == nil || len(envelope.Metadata.FullMetadata) == 0 {
return nil, fmt.Errorf("no fullMetadata in table xattr")
}
meta, err := table.ParseMetadataBytes(envelope.Metadata.FullMetadata)
if err != nil {
return nil, fmt.Errorf("parse iceberg metadata: %w", err)
}
var index *planningIndex
if len(envelope.PlanningIndex) > 0 {
if err := json.Unmarshal(envelope.PlanningIndex, &index); err != nil {
glog.V(2).Infof("iceberg maintenance: ignoring invalid planning index cache: %v", err)
index = nil
}
}
metadataFileName := metadataFileNameFromLocation(envelope.MetadataLocation)
if metadataFileName == "" {
metadataFileName = fmt.Sprintf("v%d.metadata.json", envelope.MetadataVersion)
}
return &tableState{
Metadata: meta,
MetadataFileName: metadataFileName,
DataPath: tableDataPath(bucketName, tablePath, envelope.MetadataLocation),
PlanningIndex: index,
}, nil
}
// tableDataPath resolves the bucket-relative directory holding a table's files.
// The catalog records the table's real location in the metadata location, which
// is the catalog path only for tables the catalog placed there itself: a client
// creating a table through the REST catalog can be handed a location elsewhere
// in the bucket, e.g. "ns/table-<uuid>" when the catalog path was occupied.
// Locations outside the table's own bucket fall back to the catalog path.
func tableDataPath(bucketName, tablePath, metadataLocation string) string {
dataDir := s3tables.TableDataDirFromMetadataLocation(metadataLocation)
if rel, ok := cutPathPrefix(dataDir, path.Join(s3tables.TablesPath, bucketName)); ok && rel != "" {
return rel
}
return tablePath
}
func (idx *planningIndex) matchesSnapshot(meta table.Metadata) bool {
if idx == nil {
return false
}
currentSnap := meta.CurrentSnapshot()
if currentSnap == nil || currentSnap.ManifestList == "" {
return false
}
return idx.SnapshotID == currentSnap.SnapshotID && idx.ManifestList == currentSnap.ManifestList
}
func (idx *planningIndex) compactionEligible(config Config) (bool, bool) {
if idx == nil || idx.Compaction == nil {
return false, false
}
if idx.Compaction.ConfigHash != compactionPlanningConfigHash(config) {
return false, false
}
return idx.Compaction.Eligible, true
}
func (idx *planningIndex) rewriteManifestsEligible(config Config) (bool, bool) {
if idx == nil || idx.RewriteManifests == nil {
return false, false
}
if idx.RewriteManifests.Threshold != config.MinManifestsToRewrite {
return false, false
}
return idx.RewriteManifests.Eligible, true
}
func compactionPlanningConfigHash(config Config) string {
return fmt.Sprintf("target=%d|min=%d|strategy=%s|sortcap=%d",
config.TargetFileSizeBytes, config.MinInputFiles,
config.RewriteStrategy, config.SortMaxInputBytes)
}
func operationRequested(ops []string, wanted string) bool {
for _, op := range ops {
if op == wanted {
return true
}
}
return false
}
func mergePlanningIndexSections(index, existing *planningIndex) *planningIndex {
if index == nil || existing == nil {
return index
}
if index.SnapshotID != existing.SnapshotID || index.ManifestList != existing.ManifestList {
return index
}
if index.Compaction == nil && existing.Compaction != nil {
compactionCopy := *existing.Compaction
index.Compaction = &compactionCopy
}
if index.RewriteManifests == nil && existing.RewriteManifests != nil {
rewriteCopy := *existing.RewriteManifests
index.RewriteManifests = &rewriteCopy
}
return index
}
func buildPlanningIndexFromManifests(
ctx context.Context,
filerClient filer_pb.SeaweedFilerClient,
bucketName, dataPath string,
meta table.Metadata,
config Config,
ops []string,
manifests []iceberg.ManifestFile,
) (*planningIndex, error) {
currentSnap := meta.CurrentSnapshot()
if currentSnap == nil || currentSnap.ManifestList == "" {
return nil, nil
}
index := &planningIndex{
SnapshotID: currentSnap.SnapshotID,
ManifestList: currentSnap.ManifestList,
UpdatedAtMs: time.Now().UnixMilli(),
DataManifestCount: countDataManifests(manifests),
}
if operationRequested(ops, "compact") {
eligible, err := hasEligibleCompaction(ctx, filerClient, bucketName, dataPath, manifests, config, meta, nil)
if err != nil {
return nil, err
}
index.Compaction = &planningIndexCompaction{
ConfigHash: compactionPlanningConfigHash(config),
Eligible: eligible,
}
}
if operationRequested(ops, "rewrite_manifests") {
index.RewriteManifests = &planningIndexRewriteManifests{
Threshold: config.MinManifestsToRewrite,
Eligible: index.DataManifestCount >= config.MinManifestsToRewrite,
}
}
return index, nil
}
func persistPlanningIndex(
ctx context.Context,
client filer_pb.SeaweedFilerClient,
bucketName, tablePath string,
index *planningIndex,
) error {
if index == nil {
return nil
}
tableDir := path.Join(s3tables.TablesPath, bucketName, tablePath)
tableName := path.Base(tableDir)
parentDir := path.Dir(tableDir)
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: parentDir,
Name: tableName,
})
if err != nil {
return fmt.Errorf("lookup table entry: %w", err)
}
if resp == nil || resp.Entry == nil {
return fmt.Errorf("table entry not found")
}
existingXattr, ok := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]
if !ok || len(existingXattr) == 0 {
return fmt.Errorf("no metadata xattr on table entry")
}
var internalMeta map[string]json.RawMessage
if err := json.Unmarshal(existingXattr, &internalMeta); err != nil {
return fmt.Errorf("unmarshal metadata xattr: %w", err)
}
if existingState, err := parseTableMetadataEnvelope(existingXattr, bucketName, tablePath); err == nil {
index = mergePlanningIndexSections(index, existingState.PlanningIndex)
}
indexJSON, err := json.Marshal(index)
if err != nil {
return fmt.Errorf("marshal planning index: %w", err)
}
internalMeta["planningIndex"] = indexJSON
updatedXattr, err := json.Marshal(internalMeta)
if err != nil {
return fmt.Errorf("marshal updated metadata xattr: %w", err)
}
expectedExtended := s3tables.SnapshotExtended(resp.Entry.Extended)
resp.Entry.Extended[s3tables.ExtendedKeyMetadata] = updatedXattr
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: parentDir,
Entry: resp.Entry,
ExpectedExtended: expectedExtended,
})
if err != nil {
if status.Code(err) == codes.FailedPrecondition {
return nil
}
return fmt.Errorf("update table entry: %w", err)
}
return nil
}

View File

@@ -0,0 +1,22 @@
package iceberg
import (
"testing"
"unsafe"
)
// skipIfEqualityDeleteAvroUnsupported skips the test when running on a 32-bit
// architecture. The upstream github.com/apache/iceberg-go library declares
// manifestEntry.EqualityIDs as *[]int, but the Iceberg Avro schema declares
// equality_ids as a long array. hamba/avro rejects []int for Avro long when
// int is 32-bit (386, arm, mips), causing these tests to fail through no
// fault of seaweedfs. Tracked upstream; remove this guard once iceberg-go
// switches the field to []int32/[]int64.
func skipIfEqualityDeleteAvroUnsupported(t *testing.T) {
t.Helper()
if unsafe.Sizeof(int(0)) < 8 {
t.Skip("equality-delete Avro decoding is broken on 32-bit platforms " +
"because github.com/apache/iceberg-go declares EqualityIDs as *[]int " +
"but the Iceberg schema requires Avro long")
}
}

View File

@@ -0,0 +1,111 @@
package iceberg
import (
"fmt"
"strings"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
const (
resourceGroupNone = "none"
resourceGroupBucket = "bucket"
resourceGroupNamespace = "namespace"
resourceGroupBucketNamespace = "bucket_namespace"
)
type resourceGroupConfig struct {
GroupBy string
MaxTablesPerGroup int64
}
func readResourceGroupConfig(values map[string]*plugin_pb.ConfigValue) (resourceGroupConfig, error) {
groupBy := strings.TrimSpace(strings.ToLower(readStringConfig(values, "resource_group_by", "")))
if groupBy == "" {
groupBy = resourceGroupNone
}
switch groupBy {
case resourceGroupNone, resourceGroupBucket, resourceGroupNamespace, resourceGroupBucketNamespace:
default:
return resourceGroupConfig{}, fmt.Errorf("invalid resource_group_by %q (valid: none, bucket, namespace, bucket_namespace)", groupBy)
}
maxTablesPerGroup := readInt64Config(values, "max_tables_per_resource_group", 0)
if maxTablesPerGroup < 0 {
return resourceGroupConfig{}, fmt.Errorf("max_tables_per_resource_group must be >= 0, got %d", maxTablesPerGroup)
}
if groupBy == resourceGroupNone && maxTablesPerGroup > 0 {
return resourceGroupConfig{}, fmt.Errorf("max_tables_per_resource_group requires resource_group_by to be set")
}
return resourceGroupConfig{
GroupBy: groupBy,
MaxTablesPerGroup: maxTablesPerGroup,
}, nil
}
func (c resourceGroupConfig) enabled() bool {
return c.GroupBy != "" && c.GroupBy != resourceGroupNone
}
func resourceGroupKey(info tableInfo, groupBy string) string {
switch groupBy {
case resourceGroupBucket:
return info.BucketName
case resourceGroupNamespace:
return info.Namespace
case resourceGroupBucketNamespace:
return info.BucketName + "/" + info.Namespace
default:
return ""
}
}
func selectTablesByResourceGroup(tables []tableInfo, cfg resourceGroupConfig, maxResults int) ([]tableInfo, bool) {
if !cfg.enabled() {
if maxResults > 0 && len(tables) > maxResults {
return tables[:maxResults], true
}
return tables, false
}
grouped := make(map[string][]tableInfo)
groupOrder := make([]string, 0)
for _, table := range tables {
key := resourceGroupKey(table, cfg.GroupBy)
if _, ok := grouped[key]; !ok {
groupOrder = append(groupOrder, key)
}
grouped[key] = append(grouped[key], table)
}
selected := make([]tableInfo, 0, len(tables))
selectedPerGroup := make(map[string]int64)
for {
progress := false
for _, key := range groupOrder {
if maxResults > 0 && len(selected) >= maxResults {
return selected, len(selected) < len(tables)
}
if cfg.MaxTablesPerGroup > 0 && selectedPerGroup[key] >= cfg.MaxTablesPerGroup {
continue
}
queue := grouped[key]
if len(queue) == 0 {
continue
}
selected = append(selected, queue[0])
grouped[key] = queue[1:]
selectedPerGroup[key]++
progress = true
}
if !progress {
break
}
}
return selected, len(selected) < len(tables)
}

View File

@@ -0,0 +1,152 @@
package iceberg
import (
"strconv"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
)
// Snapshot summary property names from the Iceberg spec. iceberg-go keeps its
// own copies unexported, so the maintenance operations spell them here.
const (
summaryAddedDataFiles = "added-data-files"
summaryDeletedDataFiles = "deleted-data-files"
summaryAddedDeleteFiles = "added-delete-files"
summaryRemovedDeleteFiles = "removed-delete-files"
summaryAddedPosDeleteFiles = "added-position-delete-files"
summaryRemovedPosDelFiles = "removed-position-delete-files"
summaryAddedEqDeleteFiles = "added-equality-delete-files"
summaryRemovedEqDelFiles = "removed-equality-delete-files"
summaryAddedRecords = "added-records"
summaryDeletedRecords = "deleted-records"
summaryAddedPosDeletes = "added-position-deletes"
summaryRemovedPosDeletes = "removed-position-deletes"
summaryAddedEqDeletes = "added-equality-deletes"
summaryRemovedEqDeletes = "removed-equality-deletes"
summaryAddedFilesSize = "added-files-size"
summaryRemovedFilesSize = "removed-files-size"
summaryTotalDataFiles = "total-data-files"
summaryTotalDeleteFiles = "total-delete-files"
summaryTotalRecords = "total-records"
summaryTotalFilesSize = "total-files-size"
summaryTotalPosDeletes = "total-position-deletes"
summaryTotalEqDeletes = "total-equality-deletes"
)
// snapshotSummary accumulates the files a maintenance operation adds to and
// removes from a table, and renders them as an Iceberg snapshot summary.
//
// Engines read a table's size straight out of the current snapshot summary —
// PyIceberg's inspect.snapshots, Trino's $snapshots and Spark's DESCRIBE all
// report total-records, total-data-files and total-files-size verbatim — so a
// maintenance snapshot carrying only its own labels blanks those numbers for
// the whole table until the next writer commits.
type snapshotSummary struct {
addedFilesSize, removedFilesSize int64
addedDataFiles, removedDataFiles int64
addedRecords, removedRecords int64
addedDeleteFiles, removedDeleteFiles int64
addedPosDeleteFiles, removedPosFiles int64
addedEqDeleteFiles, removedEqFiles int64
addedPosDeletes, removedPosDeletes int64
addedEqDeletes, removedEqDeletes int64
}
func (s *snapshotSummary) addFile(df iceberg.DataFile) {
s.addedFilesSize += df.FileSizeBytes()
switch df.ContentType() {
case iceberg.EntryContentData:
s.addedDataFiles++
s.addedRecords += df.Count()
case iceberg.EntryContentPosDeletes:
s.addedDeleteFiles++
s.addedPosDeleteFiles++
s.addedPosDeletes += df.Count()
case iceberg.EntryContentEqDeletes:
s.addedDeleteFiles++
s.addedEqDeleteFiles++
s.addedEqDeletes += df.Count()
}
}
func (s *snapshotSummary) removeFile(df iceberg.DataFile) {
s.removedFilesSize += df.FileSizeBytes()
switch df.ContentType() {
case iceberg.EntryContentData:
s.removedDataFiles++
s.removedRecords += df.Count()
case iceberg.EntryContentPosDeletes:
s.removedDeleteFiles++
s.removedPosFiles++
s.removedPosDeletes += df.Count()
case iceberg.EntryContentEqDeletes:
s.removedDeleteFiles++
s.removedEqFiles++
s.removedEqDeletes += df.Count()
}
}
// build renders the summary for a snapshot replacing parent: the operation's
// own labels, the added/removed counters, and the running totals.
//
// A total is carried forward only when the parent recorded it. Iceberg treats a
// missing total as zero, which turns a compaction that replaces two files with
// one into "total-data-files: -1" — dropped as negative — or worse, a table
// with millions of rows into "total-records: 0". Leaving the field out lets a
// reader fall back to the manifests instead of believing a made-up number.
func (s snapshotSummary) build(operation table.Operation, parent *table.Snapshot, labels map[string]string) *table.Summary {
props := iceberg.Properties{}
for k, v := range labels {
props[k] = v
}
setPositive := func(key string, value int64) {
if value > 0 {
props[key] = strconv.FormatInt(value, 10)
}
}
setPositive(summaryAddedFilesSize, s.addedFilesSize)
setPositive(summaryRemovedFilesSize, s.removedFilesSize)
setPositive(summaryAddedDataFiles, s.addedDataFiles)
setPositive(summaryDeletedDataFiles, s.removedDataFiles)
setPositive(summaryAddedRecords, s.addedRecords)
setPositive(summaryDeletedRecords, s.removedRecords)
setPositive(summaryAddedDeleteFiles, s.addedDeleteFiles)
setPositive(summaryRemovedDeleteFiles, s.removedDeleteFiles)
setPositive(summaryAddedPosDeleteFiles, s.addedPosDeleteFiles)
setPositive(summaryRemovedPosDelFiles, s.removedPosFiles)
setPositive(summaryAddedEqDeleteFiles, s.addedEqDeleteFiles)
setPositive(summaryRemovedEqDelFiles, s.removedEqFiles)
setPositive(summaryAddedPosDeletes, s.addedPosDeletes)
setPositive(summaryRemovedPosDeletes, s.removedPosDeletes)
setPositive(summaryAddedEqDeletes, s.addedEqDeletes)
setPositive(summaryRemovedEqDeletes, s.removedEqDeletes)
var previous iceberg.Properties
if parent != nil && parent.Summary != nil {
previous = parent.Summary.Properties
}
carryTotal := func(key string, added, removed int64) {
raw, ok := previous[key]
if !ok {
return
}
before, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return
}
if total := before + added - removed; total >= 0 {
props[key] = strconv.FormatInt(total, 10)
}
}
carryTotal(summaryTotalDataFiles, s.addedDataFiles, s.removedDataFiles)
carryTotal(summaryTotalDeleteFiles, s.addedDeleteFiles, s.removedDeleteFiles)
carryTotal(summaryTotalRecords, s.addedRecords, s.removedRecords)
carryTotal(summaryTotalFilesSize, s.addedFilesSize, s.removedFilesSize)
carryTotal(summaryTotalPosDeletes, s.addedPosDeletes, s.removedPosDeletes)
carryTotal(summaryTotalEqDeletes, s.addedEqDeletes, s.removedEqDeletes)
return &table.Summary{Operation: operation, Properties: props}
}

View File

@@ -0,0 +1,161 @@
package iceberg
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"testing"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// seedSortableTable creates a table sorted ascending on id whose data files are
// deliberately out of order: ids interleave across the files and descend within
// each one, so neither the file order nor the row order is already sorted.
func seedSortableTable(t *testing.T, files, rowsPerFile int) (*fakeFilerServer, filer_pb.SeaweedFilerClient, tableSetup) {
t.Helper()
fs, client := startFakeFiler(t)
sortOrder, err := table.NewSortOrder(1, []table.SortField{{
SourceIDs: []int{1},
Transform: iceberg.IdentityTransform{},
Direction: table.SortASC,
NullOrder: table.NullsFirst,
}})
if err != nil {
t.Fatalf("new sort order: %v", err)
}
type dataRow = struct {
ID int64
Name string
}
dataFiles := make([]struct {
Name string
Rows []dataRow
}, 0, files)
for f := 0; f < files; f++ {
rows := make([]dataRow, 0, rowsPerFile)
for i := 0; i < rowsPerFile; i++ {
id := int64((rowsPerFile-1-i)*files + f)
rows = append(rows, dataRow{ID: id, Name: fmt.Sprintf("row-%d", id)})
}
dataFiles = append(dataFiles, struct {
Name string
Rows []dataRow
}{Name: fmt.Sprintf("d%d.parquet", f), Rows: rows})
}
setup := tableSetup{BucketName: "tb", Namespace: "ns", TableName: "tbl"}
populateTableWithDeleteFilesAndSortOrder(t, fs, setup, dataFiles, nil, nil, sortOrder)
return fs, client, setup
}
func sortCompactionConfig(spillDir string) Config {
return Config{
TargetFileSizeBytes: 256 * 1024 * 1024,
MinInputFiles: 2,
MaxCommitRetries: 3,
ApplyDeletes: true,
RewriteStrategy: "sort",
SortBufferRows: minSortBufferRows,
SortSpillDir: spillDir,
}
}
// compactedFileNames lists the outputs a compaction wrote, which is empty when
// every bin was skipped.
func compactedFileNames(fs *fakeFilerServer, setup tableSetup) []string {
dataDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "data")
var names []string
for _, e := range fs.listDir(dataDir) {
if strings.HasPrefix(e.Name, "compact-") {
names = append(names, e.Name)
}
}
return names
}
// A sorted rewrite whose bin does not fit in one buffer has to spill: each
// buffer is encoded as a sorted run and the runs are merged at close. This is
// the case that used to hold every row of the bin in memory, so it is worth
// proving both that the output is globally ordered and that the runs do not
// outlive the job.
func TestCompactDataFilesSortSpillsRunsAndCleansUp(t *testing.T) {
const files, rowsPerFile = 3, 1200 // 3600 rows against a 1024-row buffer
fs, client, setup := seedSortableTable(t, files, rowsPerFile)
spillDir := t.TempDir()
handler := NewHandler(nil)
result, _, err := handler.compactDataFiles(context.Background(), client, setup.BucketName, setup.tablePath(), sortCompactionConfig(spillDir), nil)
if err != nil {
t.Fatalf("compactDataFiles: %v", err)
}
if !strings.Contains(result, "using sort") {
t.Fatalf("expected sorted compaction result, got %q", result)
}
rows := readCompactedRows(t, fs, setup)
if len(rows) != files*rowsPerFile {
t.Fatalf("expected %d compacted rows, got %d", files*rowsPerFile, len(rows))
}
for i := 1; i < len(rows); i++ {
if rows[i-1].ID > rows[i].ID {
t.Fatalf("rows are not sorted by id at %d: %d then %d", i, rows[i-1].ID, rows[i].ID)
}
}
leftovers, err := os.ReadDir(spillDir)
if err != nil {
t.Fatalf("read spill dir: %v", err)
}
if len(leftovers) != 0 {
t.Fatalf("expected the sorted runs to be removed, found %d file(s)", len(leftovers))
}
}
// An empty spill directory at the end of a job cannot tell a run written to
// disk from one held in memory. A directory that cannot hold the runs can: the
// same data compacts with a usable one and does not with an unusable one, which
// it would not do if the runs never reached the pool. A bin whose merge fails
// is logged and skipped rather than failing the job, so the difference shows up
// as a missing output file rather than an error.
func TestCompactDataFilesSortSpillDirDecidesOutcome(t *testing.T) {
// Two files that together cross the 1024-row buffer, so a run is spilled.
const files, rowsPerFile = 2, 700
for _, tc := range []struct {
name string
spillDir func(t *testing.T) string
wantOutcomes int
}{
{
name: "usable",
spillDir: func(t *testing.T) string { return t.TempDir() },
wantOutcomes: 1,
},
{
name: "unusable",
spillDir: func(t *testing.T) string { return filepath.Join(t.TempDir(), "not-created") },
wantOutcomes: 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
fs, client, setup := seedSortableTable(t, files, rowsPerFile)
handler := NewHandler(nil)
if _, _, err := handler.compactDataFiles(context.Background(), client, setup.BucketName, setup.tablePath(), sortCompactionConfig(tc.spillDir(t)), nil); err != nil {
t.Fatalf("compactDataFiles: %v", err)
}
if names := compactedFileNames(fs, setup); len(names) != tc.wantOutcomes {
t.Fatalf("expected %d compacted file(s), got %v", tc.wantOutcomes, names)
}
})
}
}

View File

@@ -0,0 +1,202 @@
package iceberg
import (
"fmt"
"strings"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/parquet-go/parquet-go"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
type compactionRewritePlan struct {
strategy string
sortFields []compactionSortField
// Carried on the plan so the merge does not need the whole Config.
bufferRows int64
spillDir string
}
type compactionSortField struct {
path string
descending bool
nullsFirst bool
}
func resolveCompactionRewritePlan(config Config, meta table.Metadata) (*compactionRewritePlan, error) {
strategy := strings.TrimSpace(strings.ToLower(config.RewriteStrategy))
if strategy == "" {
strategy = defaultRewriteStrategy
}
if strategy == defaultRewriteStrategy {
return &compactionRewritePlan{strategy: defaultRewriteStrategy}, nil
}
// "auto" sorts tables that declare a usable sort order and bin-packs the
// rest, so an unsorted table is not an error the way an explicit "sort" is.
if strategy == rewriteStrategyAuto {
sortFields, err := resolveCompactionSortFields(meta)
if err != nil {
glog.V(2).Infof("iceberg compact: auto strategy falling back to binpack: %v", err)
return &compactionRewritePlan{strategy: defaultRewriteStrategy}, nil
}
return newSortPlan(config, sortFields), nil
}
if strategy != "sort" {
return nil, fmt.Errorf("unsupported rewrite strategy %q", config.RewriteStrategy)
}
sortFields, err := resolveCompactionSortFields(meta)
if err != nil {
if err == errUnsupportedTableSortOrder {
glog.V(2).Infof("iceberg compact: falling back to binpack because the table sort order is not supported")
return &compactionRewritePlan{strategy: defaultRewriteStrategy}, nil
}
return nil, err
}
return newSortPlan(config, sortFields), nil
}
// newSortPlan pairs the resolved sort fields with the settings the sorted
// merge needs while writing.
func newSortPlan(config Config, sortFields []compactionSortField) *compactionRewritePlan {
return &compactionRewritePlan{
strategy: "sort",
sortFields: sortFields,
bufferRows: config.SortBufferRows,
spillDir: config.SortSpillDir,
}
}
var errUnsupportedTableSortOrder = fmt.Errorf("unsupported table sort order")
func resolveCompactionSortFields(meta table.Metadata) ([]compactionSortField, error) {
if meta == nil || meta.CurrentSchema() == nil {
return nil, fmt.Errorf("sort rewrite requires table schema metadata")
}
sortOrder := meta.SortOrder()
if sortOrder.IsUnsorted() {
return nil, fmt.Errorf("sort rewrite requires a table sort order")
}
schema := meta.CurrentSchema()
seen := make(map[string]struct{})
fields := make([]compactionSortField, 0, sortOrder.Len())
for _, sortField := range sortOrder.Fields() {
if _, ok := sortField.Transform.(iceberg.IdentityTransform); !ok {
return nil, errUnsupportedTableSortOrder
}
field, ok := schema.FindFieldByID(sortField.SourceID())
if !ok {
return nil, fmt.Errorf("table sort field %d not found in schema", sortField.SourceID())
}
if _, ok := field.Type.(iceberg.PrimitiveType); !ok {
return nil, fmt.Errorf("table sort field %q is not a primitive column", field.Name)
}
columnPath, ok := schema.FindColumnName(sortField.SourceID())
if !ok {
columnPath = field.Name
}
normalizedPath := strings.ToLower(columnPath)
if _, exists := seen[normalizedPath]; exists {
return nil, fmt.Errorf("duplicate sort field %q", columnPath)
}
seen[normalizedPath] = struct{}{}
fields = append(fields, compactionSortField{
path: columnPath,
descending: sortField.Direction == table.SortDESC,
nullsFirst: sortField.NullOrder == table.NullsFirst,
})
}
if len(fields) == 0 {
return nil, fmt.Errorf("sort rewrite requires at least one sort field")
}
return fields, nil
}
func compactionTargetSizeForPlan(config Config, plan *compactionRewritePlan) int64 {
targetSize := config.TargetFileSizeBytes
if plan != nil && plan.strategy == "sort" && config.SortMaxInputBytes > 0 && config.SortMaxInputBytes < targetSize {
return config.SortMaxInputBytes
}
return targetSize
}
func filterCompactionBinsByPlan(bins []compactionBin, config Config, plan *compactionRewritePlan) []compactionBin {
if plan == nil || plan.strategy != "sort" || config.SortMaxInputBytes <= 0 {
return bins
}
filtered := make([]compactionBin, 0, len(bins))
for _, bin := range bins {
if bin.TotalSize <= config.SortMaxInputBytes {
filtered = append(filtered, bin)
}
}
return filtered
}
func compactionNoEligibleMessage(config Config, plan *compactionRewritePlan, initialBinCount int) string {
if plan != nil && plan.strategy == "sort" && config.SortMaxInputBytes > 0 && initialBinCount > 0 {
return fmt.Sprintf("no files eligible for sorted compaction within %d MB sort input cap", config.SortMaxInputBytes/bytesPerMB)
}
return "no files eligible for compaction"
}
func (p *compactionRewritePlan) summaryLabel() string {
if p == nil || p.strategy != "sort" {
return defaultRewriteStrategy
}
paths := make([]string, 0, len(p.sortFields))
for _, field := range p.sortFields {
label := field.path
if field.descending {
label += " desc"
} else {
label += " asc"
}
if field.nullsFirst {
label += " nulls-first"
} else {
label += " nulls-last"
}
paths = append(paths, label)
}
return fmt.Sprintf("sort (%s)", strings.Join(paths, ", "))
}
func (p *compactionRewritePlan) parquetSortingColumns(pqSchema *parquet.Schema) ([]parquet.SortingColumn, error) {
if p == nil || p.strategy != "sort" {
return nil, nil
}
if pqSchema == nil {
return nil, fmt.Errorf("parquet schema is required for sort rewrite")
}
columns := make([]parquet.SortingColumn, 0, len(p.sortFields))
for _, field := range p.sortFields {
path := strings.Split(field.path, ".")
if _, ok := pqSchema.Lookup(path...); !ok {
return nil, fmt.Errorf("parquet schema missing sort field %q", field.path)
}
var column parquet.SortingColumn
if field.descending {
column = parquet.Descending(path...)
} else {
column = parquet.Ascending(path...)
}
if field.nullsFirst {
column = parquet.NullsFirst(column)
}
columns = append(columns, column)
}
return columns, nil
}

View File

@@ -0,0 +1,233 @@
package iceberg
import (
"context"
"encoding/json"
"fmt"
"path"
"strconv"
"strings"
"github.com/apache/iceberg-go"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// Iceberg table properties that describe the physical layout maintenance has
// to produce.
const (
propTargetFileSize = "write.target-file-size-bytes"
propDeleteTargetFileSize = "write.delete.target-file-size-bytes"
propMaxSnapshotAgeMs = "history.expire.max-snapshot-age-ms"
propMinSnapshotsToKeep = "history.expire.min-snapshots-to-keep"
)
// resolveTableConfig layers a table's own intent over the worker config, from
// the maintenance configuration and from the table's Iceberg properties.
//
// Properties win by default: a writer targeting 512 MiB and a compactor
// rewriting to 256 MiB would rewrite each other's output forever, and the
// property is the signal every engine already honours. Operators who want the
// control plane to be authoritative instead can clear
// table_properties_override.
func resolveTableConfig(base Config, props iceberg.Properties, maintenance s3tables.MaintenanceConfiguration) Config {
cfg := base
if base.TablePropertiesOverride {
cfg = applyMaintenanceConfig(cfg, maintenance)
cfg = applyTableProperties(cfg, props)
} else {
cfg = applyTableProperties(cfg, props)
cfg = applyMaintenanceConfig(cfg, maintenance)
}
return applyThresholdDefaults(cfg)
}
func applyTableProperties(cfg Config, props iceberg.Properties) Config {
if v, ok := propInt64(props, propTargetFileSize); ok {
cfg.TargetFileSizeBytes = v
}
if v, ok := propInt64(props, propDeleteTargetFileSize); ok {
cfg.DeleteTargetFileSizeBytes = v
}
if v, ok := propInt64(props, propMaxSnapshotAgeMs); ok {
cfg.SnapshotRetentionMs = v
}
if v, ok := propInt64(props, propMinSnapshotsToKeep); ok {
cfg.MaxSnapshotsToKeep = v
}
return cfg
}
func applyMaintenanceConfig(cfg Config, maintenance s3tables.MaintenanceConfiguration) Config {
if s := compactionSettings(maintenance); s != nil {
if v := settingValue(s.TargetFileSizeMB); v > 0 {
cfg.TargetFileSizeBytes = mbToBytes(v)
}
switch s.Strategy {
case s3tables.CompactionStrategyBinpack, s3tables.CompactionStrategySort, s3tables.CompactionStrategyAuto:
cfg.RewriteStrategy = s.Strategy
}
}
if s := snapshotSettings(maintenance); s != nil {
if v := settingValue(s.MaxSnapshotAgeHours); v > 0 {
cfg.SnapshotRetentionMs = hoursToMs(v)
}
if v := settingValue(s.MinSnapshotsToKeep); v > 0 {
cfg.MaxSnapshotsToKeep = v
}
}
// AWS marks a file non-current after unreferencedDays and deletes it a
// further nonCurrentDays later. remove_orphans deletes in one step, so the
// cutoff has to be the sum or the recovery window disappears.
if s := orphanSettings(maintenance); s != nil {
if days := addDays(settingValue(s.UnreferencedDays), settingValue(s.NonCurrentDays)); days > 0 {
cfg.OrphanOlderThanHours = daysToHours(days)
}
}
return cfg
}
// settingValue reads an optional maintenance setting, treating unset and
// nonsensical values alike so the worker keeps its own configuration.
func settingValue(v *int64) int64 {
if v == nil || *v < 0 {
return 0
}
return *v
}
func compactionSettings(maintenance s3tables.MaintenanceConfiguration) *s3tables.IcebergCompactionSettings {
if v := enabledSettings(maintenance, s3tables.MaintenanceTypeIcebergCompaction); v != nil {
return v.IcebergCompaction
}
return nil
}
func snapshotSettings(maintenance s3tables.MaintenanceConfiguration) *s3tables.IcebergSnapshotManagementSettings {
if v := enabledSettings(maintenance, s3tables.MaintenanceTypeIcebergSnapshotManagement); v != nil {
return v.IcebergSnapshotManagement
}
return nil
}
func orphanSettings(maintenance s3tables.MaintenanceConfiguration) *s3tables.IcebergUnreferencedFileRemovalSettings {
if v := enabledSettings(maintenance, s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval); v != nil {
return v.IcebergUnreferencedFileRemoval
}
return nil
}
// enabledSettings returns the settings for a maintenance type only when it is
// switched on; a disabled type drops its operations instead of retuning them.
func enabledSettings(maintenance s3tables.MaintenanceConfiguration, maintenanceType string) *s3tables.MaintenanceSettings {
value, ok := maintenance[maintenanceType]
if !ok || value == nil || value.Status == s3tables.MaintenanceStatusDisabled {
return nil
}
return value.Settings
}
var mergeMaintenanceConfiguration = s3tables.MergeMaintenanceConfiguration
// maintenanceJobType maps a worker operation onto the AWS job type that governs
// it. Manifest and delete-file rewrites have no AWS equivalent and ride along
// with compaction, so disabling compaction stops all rewriting.
func maintenanceJobType(op string) string {
switch op {
case "compact", "rewrite_position_delete_files", "rewrite_manifests":
return s3tables.MaintenanceTypeIcebergCompaction
case "expire_snapshots":
return s3tables.MaintenanceTypeIcebergSnapshotManagement
case "remove_orphans":
return s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval
}
return ""
}
// filterDisabledOperations drops the operations the maintenance configuration
// switched off. No table property can re-enable them.
func filterDisabledOperations(ops []string, maintenance s3tables.MaintenanceConfiguration) []string {
if len(maintenance) == 0 {
return ops
}
filtered := make([]string, 0, len(ops))
for _, op := range ops {
value, ok := maintenance[maintenanceJobType(op)]
if ok && value != nil && value.Status == s3tables.MaintenanceStatusDisabled {
continue
}
filtered = append(filtered, op)
}
return filtered
}
// loadMaintenanceConfiguration reads the bucket and table maintenance
// configuration and overlays the table's on the bucket's. Detection gets both
// from entries it already listed; execution has to look them up.
func loadMaintenanceConfiguration(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string) (s3tables.MaintenanceConfiguration, error) {
bucketDir := path.Join(s3tables.TablesPath, bucketName)
tableDir := path.Join(bucketDir, tablePath)
bucket, err := lookupMaintenanceConfiguration(ctx, client, bucketDir, bucketName)
if err != nil {
return nil, err
}
table, err := lookupMaintenanceConfiguration(ctx, client, tableDir, path.Join(bucketName, tablePath))
if err != nil {
return nil, err
}
return mergeMaintenanceConfiguration(bucket, table), nil
}
func lookupMaintenanceConfiguration(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, resource string) (s3tables.MaintenanceConfiguration, error) {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: path.Dir(dir),
Name: path.Base(dir),
})
if err != nil {
return nil, fmt.Errorf("look up %s: %w", resource, err)
}
if resp == nil || resp.Entry == nil {
return nil, nil
}
return parseMaintenanceConfiguration(resp.Entry.Extended, resource)
}
// parseMaintenanceConfiguration reads the maintenance configuration stored on a
// filer entry. An unreadable attribute is an error rather than an empty
// configuration: treating it as empty would run operations the operator
// disabled.
func parseMaintenanceConfiguration(extended map[string][]byte, resource string) (s3tables.MaintenanceConfiguration, error) {
data, ok := extended[s3tables.ExtendedKeyMaintenance]
if !ok || len(data) == 0 {
return nil, nil
}
var config s3tables.MaintenanceConfiguration
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parse maintenance configuration on %s: %w", resource, err)
}
return config, nil
}
// propInt64 reads a positive integer table property. Anything unset,
// unparseable or non-positive leaves the worker config in place so a
// misconfigured table never stops its own maintenance.
func propInt64(props iceberg.Properties, key string) (int64, bool) {
raw, ok := props[key]
if !ok {
return 0, false
}
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
glog.V(1).Infof("iceberg maintenance: ignoring table property %s=%q: not an integer", key, raw)
return 0, false
}
if value <= 0 {
glog.V(1).Infof("iceberg maintenance: ignoring table property %s=%d: not positive", key, value)
return 0, false
}
return value, true
}

View File

@@ -0,0 +1,406 @@
package iceberg
import (
"errors"
"math"
"testing"
"time"
"github.com/apache/iceberg-go"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func settingPtr(v int64) *int64 { return &v }
func baseTestConfig() Config {
return applyThresholdDefaults(Config{
SnapshotRetentionMs: hoursToMs(defaultSnapshotRetentionHours),
MaxSnapshotsToKeep: defaultMaxSnapshotsToKeep,
TablePropertiesOverride: true,
})
}
func TestResolveTableConfigNoProperties(t *testing.T) {
base := baseTestConfig()
got := resolveTableConfig(base, iceberg.Properties{}, nil)
if got != base {
t.Errorf("expected config untouched, got %+v", got)
}
if got := resolveTableConfig(base, nil, nil); got != base {
t.Errorf("expected config untouched for nil properties, got %+v", got)
}
}
func TestResolveTableConfigPropertiesWin(t *testing.T) {
base := baseTestConfig()
got := resolveTableConfig(base, iceberg.Properties{
propTargetFileSize: "536870912",
propDeleteTargetFileSize: "33554432",
propMaxSnapshotAgeMs: "432000000",
propMinSnapshotsToKeep: "1",
}, nil)
if got.TargetFileSizeBytes != 536870912 {
t.Errorf("expected TargetFileSizeBytes=536870912, got %d", got.TargetFileSizeBytes)
}
if got.DeleteTargetFileSizeBytes != 33554432 {
t.Errorf("expected DeleteTargetFileSizeBytes=33554432, got %d", got.DeleteTargetFileSizeBytes)
}
if got.SnapshotRetentionMs != 432000000 {
t.Errorf("expected SnapshotRetentionMs=432000000, got %d", got.SnapshotRetentionMs)
}
if got.MaxSnapshotsToKeep != 1 {
t.Errorf("expected MaxSnapshotsToKeep=1, got %d", got.MaxSnapshotsToKeep)
}
}
// A sub-hour retention has to survive resolution; truncating it to whole hours
// would round down to zero and get clamped back up to the default.
func TestResolveTableConfigSubHourRetention(t *testing.T) {
got := resolveTableConfig(baseTestConfig(), iceberg.Properties{propMaxSnapshotAgeMs: "1"}, nil)
if got.SnapshotRetentionMs != 1 {
t.Errorf("expected SnapshotRetentionMs=1, got %d", got.SnapshotRetentionMs)
}
}
func TestResolveTableConfigIgnoresUnusableValues(t *testing.T) {
base := baseTestConfig()
for name, value := range map[string]string{
"not a number": "512mb",
"empty": "",
"zero": "0",
"negative": "-1",
"overflow": "99999999999999999999",
} {
t.Run(name, func(t *testing.T) {
got := resolveTableConfig(base, iceberg.Properties{propTargetFileSize: value}, nil)
if got.TargetFileSizeBytes != base.TargetFileSizeBytes {
t.Errorf("expected fallback to %d, got %d", base.TargetFileSizeBytes, got.TargetFileSizeBytes)
}
})
}
}
func TestResolveTableConfigTrimsWhitespace(t *testing.T) {
got := resolveTableConfig(baseTestConfig(), iceberg.Properties{propTargetFileSize: " 536870912\n"}, nil)
if got.TargetFileSizeBytes != 536870912 {
t.Errorf("expected TargetFileSizeBytes=536870912, got %d", got.TargetFileSizeBytes)
}
}
func TestResolveTableConfigLeavesOtherFields(t *testing.T) {
base := baseTestConfig()
base.Operations = "compact"
base.Where = "day = 3"
base.MinInputFiles = 9
got := resolveTableConfig(base, iceberg.Properties{propTargetFileSize: "536870912"}, nil)
if got.Operations != "compact" || got.Where != "day = 3" || got.MinInputFiles != 9 {
t.Errorf("expected unrelated fields preserved, got %+v", got)
}
}
// An orphan cutoff large enough to overflow time.Duration would wrap negative,
// put the cutoff in the future and make every file look like an orphan.
func TestApplyThresholdDefaultsClampsOrphanCutoff(t *testing.T) {
got := applyThresholdDefaults(Config{OrphanOlderThanHours: math.MaxInt64})
if got.OrphanOlderThanHours != maxOrphanOlderThanHours {
t.Fatalf("expected the cutoff clamped to %d, got %d", maxOrphanOlderThanHours, got.OrphanOlderThanHours)
}
if cutoff := time.Duration(got.OrphanOlderThanHours) * time.Hour; cutoff <= 0 {
t.Errorf("expected a positive cutoff duration, got %v", cutoff)
}
if got := applyThresholdDefaults(Config{OrphanOlderThanHours: 72}); got.OrphanOlderThanHours != 72 {
t.Errorf("expected a normal cutoff untouched, got %d", got.OrphanOlderThanHours)
}
}
func maintenanceConfig(entries map[string]*s3tables.MaintenanceConfigurationValue) s3tables.MaintenanceConfiguration {
return s3tables.MaintenanceConfiguration(entries)
}
func TestResolveTableConfigAppliesMaintenanceConfig(t *testing.T) {
got := resolveTableConfig(baseTestConfig(), nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergCompaction: {
Status: s3tables.MaintenanceStatusEnabled,
Settings: &s3tables.MaintenanceSettings{IcebergCompaction: &s3tables.IcebergCompactionSettings{TargetFileSizeMB: settingPtr(512)}},
},
s3tables.MaintenanceTypeIcebergSnapshotManagement: {
Status: s3tables.MaintenanceStatusEnabled,
Settings: &s3tables.MaintenanceSettings{IcebergSnapshotManagement: &s3tables.IcebergSnapshotManagementSettings{
MinSnapshotsToKeep: settingPtr(2),
MaxSnapshotAgeHours: settingPtr(120),
}},
},
}))
if got.TargetFileSizeBytes != 512*bytesPerMB {
t.Errorf("expected TargetFileSizeBytes=%d, got %d", 512*bytesPerMB, got.TargetFileSizeBytes)
}
if got.MaxSnapshotsToKeep != 2 {
t.Errorf("expected MaxSnapshotsToKeep=2, got %d", got.MaxSnapshotsToKeep)
}
if got.SnapshotRetentionMs != hoursToMs(120) {
t.Errorf("expected SnapshotRetentionMs=%d, got %d", hoursToMs(120), got.SnapshotRetentionMs)
}
}
// The default: a table declaring its own layout beats the control plane, so a
// writer and the compactor cannot disagree about target file size.
func TestResolveTableConfigPropertyBeatsMaintenanceConfig(t *testing.T) {
maintenance := maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergCompaction: {
Status: s3tables.MaintenanceStatusEnabled,
Settings: &s3tables.MaintenanceSettings{IcebergCompaction: &s3tables.IcebergCompactionSettings{TargetFileSizeMB: settingPtr(128)}},
},
})
props := iceberg.Properties{propTargetFileSize: "536870912"}
got := resolveTableConfig(baseTestConfig(), props, maintenance)
if got.TargetFileSizeBytes != 536870912 {
t.Errorf("expected the property to win with 536870912, got %d", got.TargetFileSizeBytes)
}
base := baseTestConfig()
base.TablePropertiesOverride = false
got = resolveTableConfig(base, props, maintenance)
if got.TargetFileSizeBytes != 128*bytesPerMB {
t.Errorf("expected the maintenance config to win with %d, got %d", 128*bytesPerMB, got.TargetFileSizeBytes)
}
}
// A disabled type drops its operations rather than contributing settings.
func TestResolveTableConfigIgnoresDisabledSettings(t *testing.T) {
base := baseTestConfig()
got := resolveTableConfig(base, nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergCompaction: {
Status: s3tables.MaintenanceStatusDisabled,
Settings: &s3tables.MaintenanceSettings{IcebergCompaction: &s3tables.IcebergCompactionSettings{TargetFileSizeMB: settingPtr(512)}},
},
}))
if got.TargetFileSizeBytes != base.TargetFileSizeBytes {
t.Errorf("expected disabled settings ignored, got %d", got.TargetFileSizeBytes)
}
}
func TestFilterDisabledOperations(t *testing.T) {
all := []string{"compact", "rewrite_position_delete_files", "expire_snapshots", "remove_orphans", "rewrite_manifests"}
if got := filterDisabledOperations(all, nil); len(got) != len(all) {
t.Errorf("expected no filtering without a configuration, got %v", got)
}
got := filterDisabledOperations(all, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergCompaction: {Status: s3tables.MaintenanceStatusDisabled},
s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval: {Status: s3tables.MaintenanceStatusDisabled},
}))
want := []string{"expire_snapshots"}
if len(got) != len(want) || got[0] != want[0] {
t.Errorf("expected %v, got %v", want, got)
}
if got := filterDisabledOperations(all, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergCompaction: {Status: s3tables.MaintenanceStatusEnabled},
})); len(got) != len(all) {
t.Errorf("expected an enabled type to filter nothing, got %v", got)
}
}
func TestMergeMaintenanceConfigurationTableWins(t *testing.T) {
bucket := maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval: {Status: s3tables.MaintenanceStatusEnabled},
s3tables.MaintenanceTypeIcebergCompaction: {Status: s3tables.MaintenanceStatusEnabled},
})
table := maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergCompaction: {Status: s3tables.MaintenanceStatusDisabled},
})
merged := mergeMaintenanceConfiguration(bucket, table)
if merged[s3tables.MaintenanceTypeIcebergCompaction].Status != s3tables.MaintenanceStatusDisabled {
t.Error("expected the table entry to win")
}
if merged[s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval].Status != s3tables.MaintenanceStatusEnabled {
t.Error("expected the bucket-only entry retained")
}
if got := mergeMaintenanceConfiguration(nil, table); len(got) != 1 {
t.Errorf("expected the table configuration passed through, got %v", got)
}
if got := mergeMaintenanceConfiguration(bucket, nil); len(got) != 2 {
t.Errorf("expected the bucket configuration passed through, got %v", got)
}
}
func TestParseMaintenanceConfiguration(t *testing.T) {
got, err := parseMaintenanceConfiguration(nil, "bucket")
if err != nil || got != nil {
t.Errorf("expected nil for a missing attribute, got %v (%v)", got, err)
}
// Unreadable must be an error, not an empty configuration: empty would run
// operations the operator disabled.
if _, err := parseMaintenanceConfiguration(map[string][]byte{s3tables.ExtendedKeyMaintenance: []byte("{")}, "bucket"); err == nil {
t.Error("expected an error for a malformed attribute")
}
data := []byte(`{"icebergCompaction":{"status":"disabled"}}`)
got, err = parseMaintenanceConfiguration(map[string][]byte{s3tables.ExtendedKeyMaintenance: data}, "bucket")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got[s3tables.MaintenanceTypeIcebergCompaction].Status != s3tables.MaintenanceStatusDisabled {
t.Errorf("expected compaction disabled, got %v", got)
}
}
// unreferencedDays large enough to overflow must not land a cutoff in the future.
func TestResolveTableConfigClampsUnreferencedDays(t *testing.T) {
got := resolveTableConfig(baseTestConfig(), nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval: {
Status: s3tables.MaintenanceStatusEnabled,
Settings: &s3tables.MaintenanceSettings{
IcebergUnreferencedFileRemoval: &s3tables.IcebergUnreferencedFileRemovalSettings{UnreferencedDays: settingPtr(math.MaxInt64)},
},
},
}))
if got.OrphanOlderThanHours != maxOrphanOlderThanHours {
t.Fatalf("expected the cutoff clamped to %d, got %d", maxOrphanOlderThanHours, got.OrphanOlderThanHours)
}
if cutoff := time.Duration(got.OrphanOlderThanHours) * time.Hour; cutoff <= 0 {
t.Errorf("expected a positive cutoff duration, got %v", cutoff)
}
got = resolveTableConfig(baseTestConfig(), nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval: {
Status: s3tables.MaintenanceStatusEnabled,
Settings: &s3tables.MaintenanceSettings{
IcebergUnreferencedFileRemoval: &s3tables.IcebergUnreferencedFileRemovalSettings{UnreferencedDays: settingPtr(3)},
},
},
}))
if got.OrphanOlderThanHours != 72 {
t.Errorf("expected 3 days to be 72 hours, got %d", got.OrphanOlderThanHours)
}
}
// AWS marks a file non-current after unreferencedDays and deletes it
// nonCurrentDays later; deleting in one step has to wait for both.
func TestResolveTableConfigAddsNonCurrentWindow(t *testing.T) {
orphan := func(unreferenced, nonCurrent int64) Config {
return resolveTableConfig(baseTestConfig(), nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval: {
Status: s3tables.MaintenanceStatusEnabled,
Settings: &s3tables.MaintenanceSettings{
IcebergUnreferencedFileRemoval: &s3tables.IcebergUnreferencedFileRemovalSettings{
UnreferencedDays: settingPtr(unreferenced),
NonCurrentDays: settingPtr(nonCurrent),
},
},
},
}))
}
if got := orphan(3, 10); got.OrphanOlderThanHours != 13*24 {
t.Errorf("expected 3+10 days to be %d hours, got %d", 13*24, got.OrphanOlderThanHours)
}
if got := orphan(0, 10); got.OrphanOlderThanHours != 10*24 {
t.Errorf("expected nonCurrentDays alone to apply, got %d", got.OrphanOlderThanHours)
}
if got := orphan(math.MaxInt64, math.MaxInt64); got.OrphanOlderThanHours != maxOrphanOlderThanHours {
t.Errorf("expected the sum clamped, got %d", got.OrphanOlderThanHours)
}
}
func TestBuildJobStatus(t *testing.T) {
ranAt := time.Unix(1755250000, 0).UTC()
jobStatus := buildJobStatus(map[string]error{
"compact": nil,
"expire_snapshots": nil,
}, ranAt)
compaction := jobStatus[s3tables.MaintenanceTypeIcebergCompaction]
if compaction == nil || compaction.Status != s3tables.MaintenanceJobStatusSuccessful {
t.Fatalf("expected compaction successful, got %+v", compaction)
}
if compaction.LastRunTimestamp == nil || !compaction.LastRunTimestamp.Equal(ranAt) {
t.Errorf("expected lastRunTimestamp %v, got %v", ranAt, compaction.LastRunTimestamp)
}
if _, ok := jobStatus[s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval]; ok {
t.Error("expected an operation that did not run to be left out")
}
}
// Several operations share one AWS job type, so one failure fails the type.
func TestBuildJobStatusFailureWins(t *testing.T) {
jobStatus := buildJobStatus(map[string]error{
"compact": nil,
"rewrite_manifests": errors.New("commit conflict"),
}, time.Unix(1755250000, 0).UTC())
compaction := jobStatus[s3tables.MaintenanceTypeIcebergCompaction]
if compaction.Status != s3tables.MaintenanceJobStatusFailed {
t.Errorf("expected compaction failed, got %q", compaction.Status)
}
if compaction.FailureMessage != "commit conflict" {
t.Errorf("expected the failure message carried, got %q", compaction.FailureMessage)
}
}
func TestBuildJobStatusEmpty(t *testing.T) {
if got := buildJobStatus(nil, time.Now()); len(got) != 0 {
t.Errorf("expected an empty status, got %v", got)
}
}
func TestResolveTableConfigAppliesCompactionStrategy(t *testing.T) {
withStrategy := func(strategy string) Config {
return resolveTableConfig(baseTestConfig(), nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
s3tables.MaintenanceTypeIcebergCompaction: {
Status: s3tables.MaintenanceStatusEnabled,
Settings: &s3tables.MaintenanceSettings{IcebergCompaction: &s3tables.IcebergCompactionSettings{Strategy: strategy}},
},
}))
}
if got := withStrategy(s3tables.CompactionStrategySort); got.RewriteStrategy != "sort" {
t.Errorf("expected sort, got %q", got.RewriteStrategy)
}
if got := withStrategy(s3tables.CompactionStrategyBinpack); got.RewriteStrategy != "binpack" {
t.Errorf("expected binpack, got %q", got.RewriteStrategy)
}
// "auto" is carried through so the plan can pick per table.
if got := withStrategy(s3tables.CompactionStrategyAuto); got.RewriteStrategy != rewriteStrategyAuto {
t.Errorf("expected auto preserved, got %q", got.RewriteStrategy)
}
}
// AWS defines auto as sorting tables that declare a sort order and bin-packing
// the rest, so it must not collapse to the worker default.
func TestResolveCompactionRewritePlanAuto(t *testing.T) {
cfg := baseTestConfig()
cfg.RewriteStrategy = rewriteStrategyAuto
unsorted := buildTestMetadata(t, nil, nil, 0, nil, nil, nil)
plan, err := resolveCompactionRewritePlan(cfg, unsorted)
if err != nil {
t.Fatalf("auto must not fail on an unsorted table: %v", err)
}
if plan.strategy != defaultRewriteStrategy {
t.Errorf("expected binpack for an unsorted table, got %q", plan.strategy)
}
// An explicit sort request on the same table is still an error, so auto is
// doing something the existing strategies did not.
cfg.RewriteStrategy = "sort"
if _, err := resolveCompactionRewritePlan(cfg, unsorted); err == nil {
t.Error("expected explicit sort to fail without a table sort order")
}
}

Binary file not shown.

View File

@@ -0,0 +1,26 @@
package iceberg
import (
"context"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// The following methods export the internal maintenance operations for use
// by integration tests. They are intentionally thin wrappers.
func (h *Handler) ExpireSnapshots(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
return h.expireSnapshots(ctx, client, bucketName, tablePath, config)
}
func (h *Handler) RemoveOrphans(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
return h.removeOrphans(ctx, client, bucketName, tablePath, config)
}
func (h *Handler) RewriteManifests(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
return h.rewriteManifests(ctx, client, bucketName, tablePath, config)
}
func (h *Handler) CompactDataFiles(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
return h.compactDataFiles(ctx, client, bucketName, tablePath, config, nil)
}

View File

@@ -0,0 +1,311 @@
package iceberg
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
)
var (
whereEqualsPattern = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$`)
whereInPattern = regexp.MustCompile(`^(?i)([A-Za-z_][A-Za-z0-9_]*)\s+IN\s*\((.*)\)$`)
)
type whereClause struct {
Field string
Literals []string
}
type partitionPredicate struct {
Clauses []whereClause
}
func validateWhereOperations(where string, ops []string) error {
if strings.TrimSpace(where) == "" {
return nil
}
for _, op := range ops {
switch op {
case "compact", "rewrite_manifests", "rewrite_position_delete_files":
continue
default:
return fmt.Errorf("where filter is only supported for compact, rewrite_position_delete_files, and rewrite_manifests")
}
}
return nil
}
func parsePartitionPredicate(where string, meta table.Metadata) (*partitionPredicate, error) {
where = strings.TrimSpace(where)
if where == "" {
return nil, nil
}
if meta == nil {
return nil, fmt.Errorf("where filter requires table metadata")
}
specs := meta.PartitionSpecs()
if len(specs) == 0 || meta.PartitionSpec().IsUnpartitioned() {
return nil, fmt.Errorf("where filter is not supported for unpartitioned tables")
}
rawClauses := splitWhereConjunction(where)
clauses := make([]whereClause, 0, len(rawClauses))
for _, raw := range rawClauses {
clause, err := parseWhereClause(raw)
if err != nil {
return nil, err
}
clauses = append(clauses, clause)
}
// Validate against the current partition spec only. Historical specs may
// lack fields added during schema evolution; per-entry matching in Matches()
// handles those gracefully.
currentSpec := meta.PartitionSpec()
for _, clause := range clauses {
if !specHasFieldByName(currentSpec, clause.Field) {
return nil, fmt.Errorf("where field %q is not present in current partition spec %d", clause.Field, currentSpec.ID())
}
}
return &partitionPredicate{Clauses: clauses}, nil
}
func splitWhereConjunction(where string) []string {
// Quote-aware split: only split on AND that appears outside quotes.
var parts []string
var current strings.Builder
var quote rune
runes := []rune(where)
for i := 0; i < len(runes); i++ {
r := runes[i]
if quote != 0 {
current.WriteRune(r)
if r == quote {
quote = 0
}
continue
}
if r == '\'' || r == '"' {
quote = r
current.WriteRune(r)
continue
}
// Check for case-insensitive AND surrounded by whitespace.
if (r == 'A' || r == 'a') && i+3 < len(runes) {
candidate := string(runes[i : i+3])
if strings.EqualFold(candidate, "AND") {
before := i > 0 && isWhitespace(runes[i-1])
after := i+3 < len(runes) && isWhitespace(runes[i+3])
if before && after {
part := strings.TrimSpace(current.String())
if part != "" {
parts = append(parts, part)
}
current.Reset()
i += 3 // skip "AND" + the after-space will be consumed next iteration
continue
}
}
}
current.WriteRune(r)
}
if part := strings.TrimSpace(current.String()); part != "" {
parts = append(parts, part)
}
return parts
}
func isWhitespace(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
}
func parseWhereClause(raw string) (whereClause, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return whereClause{}, fmt.Errorf("empty where clause")
}
if matches := whereInPattern.FindStringSubmatch(raw); matches != nil {
literals, err := splitLiteralList(matches[2])
if err != nil {
return whereClause{}, err
}
if len(literals) == 0 {
return whereClause{}, fmt.Errorf("empty IN list in where clause %q", raw)
}
return whereClause{Field: matches[1], Literals: literals}, nil
}
if matches := whereEqualsPattern.FindStringSubmatch(raw); matches != nil {
return whereClause{Field: matches[1], Literals: []string{strings.TrimSpace(matches[2])}}, nil
}
return whereClause{}, fmt.Errorf("unsupported where clause %q", raw)
}
func splitLiteralList(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var (
literals []string
current strings.Builder
quote rune
)
for _, r := range raw {
switch {
case quote != 0:
current.WriteRune(r)
if r == quote {
quote = 0
}
case r == '\'' || r == '"':
quote = r
current.WriteRune(r)
case r == ',':
literal := strings.TrimSpace(current.String())
if literal != "" {
literals = append(literals, literal)
}
current.Reset()
default:
current.WriteRune(r)
}
}
if quote != 0 {
return nil, fmt.Errorf("unterminated quoted literal in IN list")
}
if literal := strings.TrimSpace(current.String()); literal != "" {
literals = append(literals, literal)
}
return literals, nil
}
func specHasFieldByName(spec iceberg.PartitionSpec, fieldName string) bool {
for _, field := range spec.Fields() {
if field.Name == fieldName {
return true
}
}
return false
}
func specByID(meta table.Metadata) map[int]iceberg.PartitionSpec {
result := make(map[int]iceberg.PartitionSpec)
if meta == nil {
return result
}
for _, spec := range meta.PartitionSpecs() {
result[spec.ID()] = spec
}
return result
}
func (p *partitionPredicate) Matches(spec iceberg.PartitionSpec, partition map[int]any) (bool, error) {
if p == nil {
return true, nil
}
valuesByName := make(map[string]any)
for _, field := range spec.Fields() {
if value, ok := partition[field.FieldID]; ok {
valuesByName[field.Name] = value
}
}
for _, clause := range p.Clauses {
actual, ok := valuesByName[clause.Field]
if !ok {
// Field not present in this spec (e.g. older spec before schema
// evolution). Skip this entry rather than erroring.
return false, nil
}
matched := false
for _, literal := range clause.Literals {
ok, err := literalMatchesActual(literal, actual)
if err != nil {
return false, fmt.Errorf("where field %q: %w", clause.Field, err)
}
if ok {
matched = true
break
}
}
if !matched {
return false, nil
}
}
return true, nil
}
func literalMatchesActual(raw string, actual any) (bool, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return false, fmt.Errorf("empty literal")
}
switch v := actual.(type) {
case string:
value, err := unquoteLiteral(raw)
if err != nil {
return false, err
}
return v == value, nil
case bool:
value, err := strconv.ParseBool(strings.ToLower(strings.TrimSpace(raw)))
if err != nil {
return false, fmt.Errorf("parse bool literal %q: %w", raw, err)
}
return v == value, nil
case int:
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return false, fmt.Errorf("parse int literal %q: %w", raw, err)
}
return int64(v) == value, nil
case int32:
value, err := strconv.ParseInt(raw, 10, 32)
if err != nil {
return false, fmt.Errorf("parse int32 literal %q: %w", raw, err)
}
return v == int32(value), nil
case int64:
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return false, fmt.Errorf("parse int64 literal %q: %w", raw, err)
}
return v == value, nil
case float32:
value, err := strconv.ParseFloat(raw, 32)
if err != nil {
return false, fmt.Errorf("parse float32 literal %q: %w", raw, err)
}
return v == float32(value), nil
case float64:
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return false, fmt.Errorf("parse float64 literal %q: %w", raw, err)
}
return v == value, nil
default:
value, err := unquoteLiteral(raw)
if err != nil {
return false, err
}
return fmt.Sprint(actual) == value, nil
}
}
func unquoteLiteral(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if len(raw) >= 2 {
if (raw[0] == '\'' && raw[len(raw)-1] == '\'') || (raw[0] == '"' && raw[len(raw)-1] == '"') {
return raw[1 : len(raw)-1], nil
}
}
return raw, nil
}

View File

@@ -0,0 +1,287 @@
package iceberg
import (
"bytes"
"context"
"encoding/json"
"fmt"
"path"
"strings"
"testing"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
type partitionedTestFile struct {
Name string
Partition map[int]any
Rows []struct {
ID int64
Name string
}
}
func populatePartitionedDataTable(
t *testing.T,
fs *fakeFilerServer,
setup tableSetup,
partitionSpec iceberg.PartitionSpec,
manifestGroups [][]partitionedTestFile,
) table.Metadata {
t.Helper()
schema := newTestSchema()
meta, err := table.NewMetadata(schema, &partitionSpec, table.UnsortedSortOrder, "s3://"+setup.BucketName+"/"+setup.tablePath(), nil)
if err != nil {
t.Fatalf("create metadata: %v", err)
}
bucketsPath := s3tables.TablesPath
bucketPath := path.Join(bucketsPath, setup.BucketName)
nsPath := path.Join(bucketPath, setup.Namespace)
tablePath := path.Join(nsPath, setup.TableName)
metaDir := path.Join(tablePath, "metadata")
dataDir := path.Join(tablePath, "data")
version := meta.Version()
var manifestFiles []iceberg.ManifestFile
for idx, group := range manifestGroups {
entries := make([]iceberg.ManifestEntry, 0, len(group))
for _, file := range group {
data := writeTestParquetFile(t, fs, dataDir, file.Name, file.Rows)
dfBuilder, err := iceberg.NewDataFileBuilder(
partitionSpec,
iceberg.EntryContentData,
path.Join("data", file.Name),
iceberg.ParquetFile,
file.Partition,
nil, nil,
int64(len(file.Rows)),
int64(len(data)),
)
if err != nil {
t.Fatalf("build data file %s: %v", file.Name, err)
}
snapID := int64(1)
entries = append(entries, iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build()))
}
manifestName := fmt.Sprintf("where-manifest-%d.avro", idx+1)
var manifestBuf bytes.Buffer
mf, err := iceberg.WriteManifest(path.Join("metadata", manifestName), &manifestBuf, version, partitionSpec, schema, 1, entries)
if err != nil {
t.Fatalf("write manifest %d: %v", idx+1, err)
}
fs.putEntry(metaDir, manifestName, &filer_pb.Entry{
Name: manifestName,
Content: manifestBuf.Bytes(),
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix(), FileSize: uint64(manifestBuf.Len())},
})
manifestFiles = append(manifestFiles, mf)
}
var manifestListBuf bytes.Buffer
seqNum := int64(1)
if err := iceberg.WriteManifestList(version, &manifestListBuf, 1, nil, &seqNum, 0, manifestFiles); err != nil {
t.Fatalf("write manifest list: %v", err)
}
fs.putEntry(metaDir, "snap-1.avro", &filer_pb.Entry{
Name: "snap-1.avro",
Content: manifestListBuf.Bytes(),
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix(), FileSize: uint64(manifestListBuf.Len())},
})
builder, err := table.MetadataBuilderFromBase(meta, "s3://"+setup.BucketName+"/"+setup.tablePath())
if err != nil {
t.Fatalf("metadata builder: %v", err)
}
snapshot := table.Snapshot{SnapshotID: 1, TimestampMs: time.Now().UnixMilli(), ManifestList: "metadata/snap-1.avro", SequenceNumber: 1}
if err := builder.AddSnapshot(&snapshot); err != nil {
t.Fatalf("add snapshot: %v", err)
}
if err := builder.SetSnapshotRef(table.MainBranch, 1, table.BranchRef); err != nil {
t.Fatalf("set snapshot ref: %v", err)
}
meta, err = builder.Build()
if err != nil {
t.Fatalf("build metadata: %v", err)
}
fullMetadataJSON, _ := json.Marshal(meta)
internalMeta := map[string]interface{}{
"metadataVersion": 1,
"metadataLocation": "metadata/v1.metadata.json",
"metadata": map[string]interface{}{"fullMetadata": json.RawMessage(fullMetadataJSON)},
}
xattr, _ := json.Marshal(internalMeta)
fs.putEntry(bucketsPath, setup.BucketName, &filer_pb.Entry{
Name: setup.BucketName,
IsDirectory: true,
Extended: map[string][]byte{s3tables.ExtendedKeyTableBucket: []byte("true")},
})
fs.putEntry(bucketPath, setup.Namespace, &filer_pb.Entry{Name: setup.Namespace, IsDirectory: true})
fs.putEntry(nsPath, setup.TableName, &filer_pb.Entry{
Name: setup.TableName,
IsDirectory: true,
Extended: map[string][]byte{
s3tables.ExtendedKeyMetadata: xattr,
s3tables.ExtendedKeyMetadataVersion: metadataVersionXattr(1),
},
})
return meta
}
func TestValidateWhereOperations(t *testing.T) {
if err := validateWhereOperations("name = 'us'", []string{"compact", "rewrite_manifests"}); err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
if err := validateWhereOperations("name = 'us'", []string{"expire_snapshots"}); err == nil {
t.Fatal("expected where validation to reject expire_snapshots")
}
}
func TestSplitWhereConjunctionQuoteAware(t *testing.T) {
cases := []struct {
input string
expected []string
}{
{"a = 1 AND b = 2", []string{"a = 1", "b = 2"}},
{"a = 'research AND dev'", []string{"a = 'research AND dev'"}},
{"a IN ('sales AND marketing', 'eng') AND b = 2", []string{"a IN ('sales AND marketing', 'eng')", "b = 2"}},
{"a = 1 and b = 2", []string{"a = 1", "b = 2"}},
{"a = 'x' AND b = \"y AND z\"", []string{"a = 'x'", "b = \"y AND z\""}},
}
for _, tc := range cases {
got := splitWhereConjunction(tc.input)
if len(got) != len(tc.expected) {
t.Errorf("splitWhereConjunction(%q) = %v, want %v", tc.input, got, tc.expected)
continue
}
for i := range got {
if got[i] != tc.expected[i] {
t.Errorf("splitWhereConjunction(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
}
}
}
}
func TestPartitionPredicateMatchesUsesPartitionFieldIDs(t *testing.T) {
spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
SourceIDs: []int{2},
FieldID: 1000,
Name: "name",
Transform: iceberg.IdentityTransform{},
})
predicate := &partitionPredicate{Clauses: []whereClause{{Field: "name", Literals: []string{"'us'"}}}}
match, err := predicate.Matches(spec, map[int]any{2: "us"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if match {
t.Fatal("expected source-column key to not match partition predicate")
}
}
func TestCompactDataFilesWhereFilter(t *testing.T) {
fs, client := startFakeFiler(t)
partitionSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
SourceIDs: []int{2},
FieldID: 1000,
Name: "name",
Transform: iceberg.IdentityTransform{},
})
setup := tableSetup{BucketName: "tb", Namespace: "ns", TableName: "tbl"}
populatePartitionedDataTable(t, fs, setup, partitionSpec, [][]partitionedTestFile{
{
{Name: "us-1.parquet", Partition: map[int]any{1000: "us"}, Rows: []struct {
ID int64
Name string
}{{1, "us"}}},
},
{
{Name: "us-2.parquet", Partition: map[int]any{1000: "us"}, Rows: []struct {
ID int64
Name string
}{{2, "us"}}},
},
{
{Name: "eu-1.parquet", Partition: map[int]any{1000: "eu"}, Rows: []struct {
ID int64
Name string
}{{3, "eu"}}},
{Name: "eu-2.parquet", Partition: map[int]any{1000: "eu"}, Rows: []struct {
ID int64
Name string
}{{4, "eu"}}},
},
})
handler := NewHandler(nil)
config := Config{
TargetFileSizeBytes: 256 * 1024 * 1024,
MinInputFiles: 2,
MaxCommitRetries: 3,
Where: "name = 'us'",
}
result, _, err := handler.compactDataFiles(context.Background(), client, setup.BucketName, setup.tablePath(), config, nil)
if err != nil {
t.Fatalf("compactDataFiles: %v", err)
}
if !strings.Contains(result, "compacted 2 files into 1") {
t.Fatalf("unexpected result: %q", result)
}
state, err := loadCurrentMetadata(context.Background(), client, setup.BucketName, setup.tablePath())
if err != nil {
t.Fatalf("loadCurrentMetadata: %v", err)
}
manifests, err := loadCurrentManifests(context.Background(), client, setup.BucketName, state.DataPath, state.Metadata)
if err != nil {
t.Fatalf("loadCurrentManifests: %v", err)
}
var liveDataPaths []string
for _, mf := range manifests {
if mf.ManifestContent() != iceberg.ManifestContentData {
continue
}
manifestData, err := loadFileByIcebergPath(context.Background(), client, setup.BucketName, setup.tablePath(), mf.FilePath())
if err != nil {
t.Fatalf("load data manifest: %v", err)
}
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
if err != nil {
t.Fatalf("read data manifest: %v", err)
}
for _, entry := range entries {
liveDataPaths = append(liveDataPaths, entry.DataFile().FilePath())
}
}
if len(liveDataPaths) != 3 {
t.Fatalf("expected 3 live data files after filtered compaction, got %v", liveDataPaths)
}
var compactedCount int
for _, p := range liveDataPaths {
switch {
case strings.HasPrefix(p, "s3://tb/ns/tbl/data/compact-"):
compactedCount++
case p == "data/eu-1.parquet", p == "data/eu-2.parquet":
default:
t.Fatalf("unexpected live data file %q", p)
}
}
if compactedCount != 1 {
t.Fatalf("expected exactly one compacted file, got %d in %v", compactedCount, liveDataPaths)
}
}