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

View File

@@ -0,0 +1,135 @@
package balance
import (
"context"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation/volume_move"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
"github.com/seaweedfs/seaweedfs/weed/worker/types/base"
"google.golang.org/grpc"
)
// BalanceTask implements the Task interface
type BalanceTask struct {
*base.BaseTask
server string
volumeID uint32
collection string
progress float64
grpcDialOption grpc.DialOption
}
// NewBalanceTask creates a new balance task instance
func NewBalanceTask(id string, server string, volumeID uint32, collection string, grpcDialOption grpc.DialOption) *BalanceTask {
return &BalanceTask{
BaseTask: base.NewBaseTask(id, types.TaskTypeBalance),
server: server,
volumeID: volumeID,
collection: collection,
grpcDialOption: grpcDialOption,
}
}
// Execute implements the Task interface
func (t *BalanceTask) Execute(ctx context.Context, params *worker_pb.TaskParams) error {
if params == nil {
return fmt.Errorf("task parameters are required")
}
balanceParams := params.GetBalanceParams()
if balanceParams == nil {
return fmt.Errorf("balance parameters are required")
}
// Get source and destination from unified arrays
if len(params.Sources) == 0 {
return fmt.Errorf("source is required for balance task")
}
if len(params.Targets) == 0 {
return fmt.Errorf("target is required for balance task")
}
sourceNode := params.Sources[0].Node
destNode := params.Targets[0].Node
if sourceNode == "" {
return fmt.Errorf("source node is required for balance task")
}
if destNode == "" {
return fmt.Errorf("destination node is required for balance task")
}
t.GetLogger().WithFields(map[string]interface{}{
"volume_id": t.volumeID,
"source": sourceNode,
"destination": destNode,
"collection": t.collection,
}).Info("Starting balance task - moving volume")
// The move sequence — freeze the source, copy, tail, verify the target
// matches the source before the destructive delete — is shared with the
// shell's volume.move/volume.balance commands.
mover := volume_move.NewMover(t.grpcDialOption)
err := mover.LiveMoveVolume(ctx, needle.VolumeId(t.volumeID), pb.ServerAddress(sourceNode), pb.ServerAddress(destNode), volume_move.VolumeMoveOptions{
IdleTimeout: 60 * time.Second,
IoBytePerSecond: balanceParams.IoBytePerSecond,
Progress: func(percent float64, stage string) {
t.ReportProgress(percent)
t.GetLogger().Info("move stage: %s", stage)
},
})
if err != nil {
return fmt.Errorf("move volume %d from %s to %s: %w", t.volumeID, sourceNode, destNode, err)
}
glog.Infof("Balance task completed successfully: volume %d moved from %s to %s",
t.volumeID, sourceNode, destNode)
return nil
}
// Validate implements the UnifiedTask interface
func (t *BalanceTask) Validate(params *worker_pb.TaskParams) error {
if params == nil {
return fmt.Errorf("task parameters are required")
}
balanceParams := params.GetBalanceParams()
if balanceParams == nil {
return fmt.Errorf("balance parameters are required")
}
if params.VolumeId != t.volumeID {
return fmt.Errorf("volume ID mismatch: expected %d, got %d", t.volumeID, params.VolumeId)
}
// Validate that at least one source matches our server
found := false
for _, source := range params.Sources {
if source.Node == t.server {
found = true
break
}
}
if !found {
return fmt.Errorf("no source matches expected server %s", t.server)
}
return nil
}
// EstimateTime implements the UnifiedTask interface
func (t *BalanceTask) EstimateTime(params *worker_pb.TaskParams) time.Duration {
// Basic estimate based on simulated steps
return 14 * time.Second // Sum of all step durations
}
// GetProgress returns current progress
func (t *BalanceTask) GetProgress() float64 {
return t.progress
}

View File

@@ -0,0 +1,204 @@
package balance
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/config"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
)
// Config extends BaseConfig with balance-specific settings
type Config struct {
base.BaseConfig
ImbalanceThreshold float64 `json:"imbalance_threshold"`
MinServerCount int `json:"min_server_count"`
IoBytePerSecond int64 `json:"io_byte_per_second"`
DataCenterFilter string `json:"-"` // per-detection-run, not persisted
RackFilter string `json:"-"` // per-detection-run, not persisted
NodeFilter string `json:"-"` // per-detection-run, not persisted
}
// NewDefaultConfig creates a new default balance configuration
func NewDefaultConfig() *Config {
return &Config{
BaseConfig: base.BaseConfig{
Enabled: true,
ScanIntervalSeconds: 30 * 60, // 30 minutes
MaxConcurrent: 1,
},
ImbalanceThreshold: 0.2, // 20%
MinServerCount: 2,
}
}
// GetConfigSpec returns the configuration schema for balance tasks
func GetConfigSpec() base.ConfigSpec {
return base.ConfigSpec{
Fields: []*config.Field{
{
Name: "enabled",
JSONName: "enabled",
Type: config.FieldTypeBool,
DefaultValue: true,
Required: false,
DisplayName: "Enable Balance Tasks",
Description: "Whether balance tasks should be automatically created",
HelpText: "Toggle this to enable or disable automatic balance task generation",
InputType: "checkbox",
CSSClasses: "form-check-input",
},
{
Name: "scan_interval_seconds",
JSONName: "scan_interval_seconds",
Type: config.FieldTypeInterval,
DefaultValue: 30 * 60,
MinValue: 5 * 60,
MaxValue: 2 * 60 * 60,
Required: true,
DisplayName: "Scan Interval",
Description: "How often to scan for volume distribution imbalances",
HelpText: "The system will check for volume distribution imbalances at this interval",
Placeholder: "30",
Unit: config.UnitMinutes,
InputType: "interval",
CSSClasses: "form-control",
},
{
Name: "max_concurrent",
JSONName: "max_concurrent",
Type: config.FieldTypeInt,
DefaultValue: 1,
MinValue: 1,
MaxValue: 3,
Required: true,
DisplayName: "Max Concurrent Tasks",
Description: "Maximum number of balance tasks that can run simultaneously",
HelpText: "Limits the number of balance operations running at the same time",
Placeholder: "1 (default)",
Unit: config.UnitCount,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "imbalance_threshold",
JSONName: "imbalance_threshold",
Type: config.FieldTypeFloat,
DefaultValue: 0.2,
MinValue: 0.05,
MaxValue: 0.5,
Required: true,
DisplayName: "Imbalance Threshold",
Description: "Minimum imbalance ratio to trigger balancing",
HelpText: "Volume distribution imbalances above this threshold will trigger balancing",
Placeholder: "0.20 (20%)",
Unit: config.UnitNone,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "io_byte_per_second",
JSONName: "io_byte_per_second",
Type: config.FieldTypeInt,
DefaultValue: 0,
MinValue: 0,
Required: false,
DisplayName: "Move IO Limit (bytes/sec)",
Description: "Limit each volume move's copy rate",
HelpText: "0 falls back to each volume server's own maintenance rate (-maintenanceBytePerSecond)",
Placeholder: "0 (server maintenance rate)",
Unit: config.UnitNone,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "min_server_count",
JSONName: "min_server_count",
Type: config.FieldTypeInt,
DefaultValue: 2,
MinValue: 2,
MaxValue: 10,
Required: true,
DisplayName: "Minimum Server Count",
Description: "Minimum number of servers required for balancing",
HelpText: "Balancing will only occur if there are at least this many servers",
Placeholder: "2 (default)",
Unit: config.UnitCount,
InputType: "number",
CSSClasses: "form-control",
},
},
}
}
// ToTaskPolicy converts configuration to a TaskPolicy protobuf message
func (c *Config) ToTaskPolicy() *worker_pb.TaskPolicy {
return &worker_pb.TaskPolicy{
Enabled: c.Enabled,
MaxConcurrent: int32(c.MaxConcurrent),
RepeatIntervalSeconds: int32(c.ScanIntervalSeconds),
CheckIntervalSeconds: int32(c.ScanIntervalSeconds),
TaskConfig: &worker_pb.TaskPolicy_BalanceConfig{
BalanceConfig: &worker_pb.BalanceTaskConfig{
ImbalanceThreshold: float64(c.ImbalanceThreshold),
MinServerCount: int32(c.MinServerCount),
IoBytePerSecond: c.IoBytePerSecond,
},
},
}
}
// FromTaskPolicy loads configuration from a TaskPolicy protobuf message
func (c *Config) FromTaskPolicy(policy *worker_pb.TaskPolicy) error {
if policy == nil {
return fmt.Errorf("policy is nil")
}
// Set general TaskPolicy fields
c.Enabled = policy.Enabled
c.MaxConcurrent = int(policy.MaxConcurrent)
c.ScanIntervalSeconds = int(policy.RepeatIntervalSeconds) // Direct seconds-to-seconds mapping
// Set balance-specific fields from the task config
if balanceConfig := policy.GetBalanceConfig(); balanceConfig != nil {
c.ImbalanceThreshold = float64(balanceConfig.ImbalanceThreshold)
c.MinServerCount = int(balanceConfig.MinServerCount)
c.IoBytePerSecond = balanceConfig.IoBytePerSecond
}
return nil
}
// LoadConfigFromPersistence loads configuration from the persistence layer if available
func LoadConfigFromPersistence(configPersistence interface{}) *Config {
config := NewDefaultConfig()
// Try to load from persistence if available
if persistence, ok := configPersistence.(interface {
LoadBalanceTaskPolicy() (*worker_pb.TaskPolicy, error)
}); ok {
policy, err := persistence.LoadBalanceTaskPolicy()
switch {
case err != nil:
glog.Warningf("Could not read the persisted balance configuration, falling back to defaults: %v", err)
case policy == nil:
glog.V(1).Infof("No balance configuration persisted yet, using defaults")
default:
if err := config.FromTaskPolicy(policy); err != nil {
glog.Warningf("Could not apply the persisted balance configuration, falling back to defaults: %v", err)
} else {
glog.V(1).Infof("Loaded balance configuration from persistence")
return config
}
}
} else if configPersistence != nil {
// A store was handed in but does not expose the accessor, so the persisted
// settings are silently ignored - always a wiring bug, never a normal state.
glog.Warningf("%T cannot provide the persisted balance configuration: it has no LoadBalanceTaskPolicy() method, "+
"so the compiled-in defaults are used and any saved balance settings are ignored", configPersistence)
}
glog.V(1).Infof("Using default balance configuration")
return config
}

View File

@@ -0,0 +1,839 @@
package balance
import (
"fmt"
"math"
"sort"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/topology/balancer"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/util"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Detection implements the detection logic for balance tasks.
// maxResults limits how many balance operations are returned per invocation.
// A non-positive maxResults means no explicit limit (uses a large default).
// The returned truncated flag is true when detection stopped because it hit
// maxResults rather than running out of work.
func Detection(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig, maxResults int) ([]*types.TaskDetectionResult, bool, error) {
if !config.IsEnabled() {
return nil, false, nil
}
if clusterInfo == nil {
return nil, false, nil
}
balanceConfig := config.(*Config)
if maxResults <= 0 {
maxResults = math.MaxInt32
}
// Group volumes by disk type to ensure we compare apples to apples.
// Remote-tiered volumes are skipped: a "move" copies .idx/.vif then
// Volume.Destroy on the source deletes the remote object the destination
// now points at. Mirrors shell/command_volume_balance.go.
volumesByDiskType := make(map[string][]*types.VolumeHealthMetrics)
for _, metric := range metrics {
if metric.HasRemoteCopy {
continue
}
volumesByDiskType[metric.DiskType] = append(volumesByDiskType[metric.DiskType], metric)
}
// Sort disk types for deterministic iteration order when maxResults
// spans multiple disk types.
diskTypes := make([]string, 0, len(volumesByDiskType))
for dt := range volumesByDiskType {
diskTypes = append(diskTypes, dt)
}
sort.Strings(diskTypes)
var allParams []*types.TaskDetectionResult
truncated := false
for _, diskType := range diskTypes {
remaining := maxResults - len(allParams)
if remaining <= 0 {
truncated = true
break
}
tasks, diskTruncated := detectForDiskType(diskType, volumesByDiskType[diskType], balanceConfig, clusterInfo, remaining)
allParams = append(allParams, tasks...)
if diskTruncated {
truncated = true
}
}
return allParams, truncated, nil
}
// detectForDiskType performs balance detection for a specific disk type,
// returning up to maxResults balance tasks and whether it was truncated by the limit.
func detectForDiskType(diskType string, diskMetrics []*types.VolumeHealthMetrics, balanceConfig *Config, clusterInfo *types.ClusterInfo, maxResults int) ([]*types.TaskDetectionResult, bool) {
// Skip if cluster segment is too small
minVolumeCount := 2 // More reasonable for small clusters
if len(diskMetrics) < minVolumeCount {
// Only log at verbose level to avoid spamming for small/empty disk types
glog.V(1).Infof("BALANCE [%s]: No tasks created - cluster too small (%d volumes, need ≥%d)", diskType, len(diskMetrics), minVolumeCount)
return nil, false
}
// Analyze volume distribution across servers.
// Seed from ActiveTopology so servers with matching disk type but zero
// volumes are included in the count and imbalance calculation.
// Also collect MaxVolumeCount per server to compute utilization ratios.
serverVolumeCounts := make(map[string]int)
serverMaxVolumes := make(map[string]int64)
serverDiskTotalBytes := make(map[string]uint64)
serverDiskFreeBytes := make(map[string]uint64)
if clusterInfo.ActiveTopology != nil {
topologyInfo := clusterInfo.ActiveTopology.GetTopologyInfo()
if topologyInfo != nil {
dcMatchers := wildcard.CompileWildcardMatchers(balanceConfig.DataCenterFilter)
rackMatchers := wildcard.CompileWildcardMatchers(balanceConfig.RackFilter)
nodeMatchers := wildcard.CompileWildcardMatchers(balanceConfig.NodeFilter)
for _, dc := range topologyInfo.DataCenterInfos {
if !wildcard.MatchesAnyWildcard(dcMatchers, dc.Id) {
continue
}
for _, rack := range dc.RackInfos {
if !wildcard.MatchesAnyWildcard(rackMatchers, rack.Id) {
continue
}
for _, node := range rack.DataNodeInfos {
if !wildcard.MatchesAnyWildcard(nodeMatchers, node.Id) {
continue
}
for diskTypeName, diskInfo := range node.DiskInfos {
if diskTypeName == diskType {
serverVolumeCounts[node.Id] = 0
serverMaxVolumes[node.Id] += diskInfo.MaxVolumeCount
serverDiskTotalBytes[node.Id] += diskInfo.DiskTotalBytes
serverDiskFreeBytes[node.Id] += diskInfo.DiskFreeBytes
}
}
}
}
}
}
}
hasLocationFilter := balanceConfig.DataCenterFilter != "" || balanceConfig.RackFilter != "" || balanceConfig.NodeFilter != ""
for _, metric := range diskMetrics {
if hasLocationFilter {
// Only count metrics for servers that passed filtering.
// Without this guard, out-of-scope servers are re-introduced.
if _, allowed := serverVolumeCounts[metric.Server]; !allowed {
continue
}
}
serverVolumeCounts[metric.Server]++
}
if len(serverVolumeCounts) < balanceConfig.MinServerCount {
glog.V(1).Infof("BALANCE [%s]: No tasks created - too few servers (%d servers, need ≥%d)", diskType, len(serverVolumeCounts), balanceConfig.MinServerCount)
return nil, false
}
// Seed adjustments from existing pending/assigned balance tasks so that
// effectiveCounts reflects in-flight moves and prevents over-scheduling.
var adjustments map[string]int
if clusterInfo.ActiveTopology != nil {
adjustments = clusterInfo.ActiveTopology.GetTaskServerAdjustments(topology.TaskTypeBalance)
}
if adjustments == nil {
adjustments = make(map[string]int)
}
// Servers where we can no longer find eligible volumes or plan destinations
exhaustedServers := make(map[string]bool)
// Sort servers for deterministic iteration and tie-breaking
sortedServers := make([]string, 0, len(serverVolumeCounts))
for server := range serverVolumeCounts {
sortedServers = append(sortedServers, server)
}
sort.Strings(sortedServers)
// Pre-index volumes by server with cursors to avoid O(maxResults * volumes) scanning.
// Sort each server's volumes by VolumeID for deterministic selection.
volumesByServer := make(map[string][]*types.VolumeHealthMetrics, len(serverVolumeCounts))
for _, metric := range diskMetrics {
volumesByServer[metric.Server] = append(volumesByServer[metric.Server], metric)
}
for _, vols := range volumesByServer {
sort.Slice(vols, func(i, j int) bool {
return vols[i].VolumeID < vols[j].VolumeID
})
}
serverCursors := make(map[string]int, len(serverVolumeCounts))
var results []*types.TaskDetectionResult
balanced := false
// Decide upfront whether all servers have MaxVolumeCount info.
// If any server is missing it, fall back to raw counts for ALL servers
// to avoid mixing utilization ratios (0.0–1.0) with raw counts.
allServersHaveMaxInfo := true
for _, server := range sortedServers {
if maxVol, ok := serverMaxVolumes[server]; !ok || maxVol <= 0 {
allServersHaveMaxInfo = false
glog.V(1).Infof("BALANCE [%s]: Server %s is missing MaxVolumeCount info, falling back to raw volume counts for balancing", diskType, server)
break
}
}
var serverUtilization func(server string, effectiveCount int) float64
if allServersHaveMaxInfo {
serverUtilization = func(server string, effectiveCount int) float64 {
return float64(effectiveCount) / float64(serverMaxVolumes[server])
}
} else {
serverUtilization = func(_ string, effectiveCount int) float64 {
return float64(effectiveCount)
}
}
// plannedBytes tracks data already routed to each destination earlier in this
// detection cycle, so the disk-fullness gate sees a target fill up as moves are
// planned instead of only its heartbeat-time free space (the shell equivalent
// is adjustAfterMove decrementing DiskFreeBytes).
plannedBytes := make(map[string]uint64)
// destinationFull excludes servers a projected move would push over, for the cycle.
destinationFull := make(map[string]bool)
// destinationDiskTooFull reports whether landing incomingBytes on server would put
// its disk at/over the mark, net of bytes already planned this cycle; unreported
// disks are never gated.
destinationDiskTooFull := func(server string, incomingBytes uint64) bool {
free := serverDiskFreeBytes[server]
if planned := plannedBytes[server]; planned < free {
free -= planned
} else {
free = 0
}
return balancer.DiskTooFullAfter(serverDiskTotalBytes[server], free, incomingBytes, balancer.DefaultMaxDiskUsagePercent)
}
for len(results) < maxResults {
// Compute effective volume counts with adjustments from planned moves
effectiveCounts := make(map[string]int, len(serverVolumeCounts))
for server, count := range serverVolumeCounts {
effective := count + adjustments[server]
if effective < 0 {
effective = 0
}
effectiveCounts[server] = effective
}
// Find the most and least utilized servers using utilization ratio
// (volumes / maxVolumes) so that servers with higher capacity are
// expected to hold proportionally more volumes.
maxUtilization := -1.0
minUtilization := math.Inf(1)
maxServer := ""
minServer := ""
for _, server := range sortedServers {
count := effectiveCounts[server]
util := serverUtilization(server, count)
// Min is the emptiest server that can actually receive a volume, so a
// physically full server (whose over-set maxVolumeCount makes its slot
// utilization look low) is never chosen as the destination.
if !destinationFull[server] && !destinationDiskTooFull(server, 0) && util < minUtilization {
minUtilization = util
minServer = server
}
// Max is only among non-exhausted servers since we can only move from them
if exhaustedServers[server] {
continue
}
if util > maxUtilization {
maxUtilization = util
maxServer = server
}
}
if maxServer == "" {
// All servers exhausted
glog.V(1).Infof("BALANCE [%s]: All overloaded servers exhausted after %d task(s)", diskType, len(results))
break
}
if minServer == "" {
// Every candidate destination is at/above the physical disk high-water
// mark, so no move can safely improve balance.
glog.V(1).Infof("BALANCE [%s]: No eligible destination - all candidates at/above %d%% disk usage after %d task(s)", diskType, balancer.DefaultMaxDiskUsagePercent, len(results))
break
}
// Check if utilization imbalance exceeds threshold.
// imbalanceRatio is the difference between the most and least utilized
// servers, expressed as a fraction of mean utilization.
avgUtilization := (maxUtilization + minUtilization) / 2.0
var imbalanceRatio float64
if avgUtilization > 0 {
imbalanceRatio = (maxUtilization - minUtilization) / avgUtilization
}
if imbalanceRatio <= balanceConfig.ImbalanceThreshold {
if len(results) == 0 {
glog.Infof("BALANCE [%s]: No tasks created - cluster well balanced. Imbalance=%.1f%% (threshold=%.1f%%). MaxUtil=%.1f%% on %s, MinUtil=%.1f%% on %s",
diskType, imbalanceRatio*100, balanceConfig.ImbalanceThreshold*100, maxUtilization*100, maxServer, minUtilization*100, minServer)
} else {
glog.Infof("BALANCE [%s]: Created %d task(s), cluster now balanced. Imbalance=%.1f%% (threshold=%.1f%%)",
diskType, len(results), imbalanceRatio*100, balanceConfig.ImbalanceThreshold*100)
}
balanced = true
break
}
// When the global max and min effective counts differ by at most 1,
// no single move can improve balance — it would just swap which server
// is min vs max. Stop here to avoid infinite oscillation when the
// threshold is unachievable (e.g., 11 vols across 4 servers: best is
// 3/3/3/2, imbalance=36%). We scan ALL servers' effective counts so the
// check works regardless of whether utilization or raw counts are used.
globalMaxCount, globalMinCount := 0, math.MaxInt
for _, c := range effectiveCounts {
if c > globalMaxCount {
globalMaxCount = c
}
if c < globalMinCount {
globalMinCount = c
}
}
if globalMaxCount-globalMinCount <= 1 {
if len(results) == 0 {
glog.Infof("BALANCE [%s]: No tasks created - cluster as balanced as possible. Imbalance=%.1f%% (threshold=%.1f%%), but max-min diff is %d",
diskType, imbalanceRatio*100, balanceConfig.ImbalanceThreshold*100, globalMaxCount-globalMinCount)
} else {
glog.Infof("BALANCE [%s]: Created %d task(s), cluster as balanced as possible. Imbalance=%.1f%% (threshold=%.1f%%), max-min diff=%d",
diskType, len(results), imbalanceRatio*100, balanceConfig.ImbalanceThreshold*100, globalMaxCount-globalMinCount)
}
balanced = true
break
}
// Per-move convergence guard: mirrors weed/shell/command_volume_balance.go
// to prevent oscillation and destination overshoot when MaxVolumeCount
// values are heterogeneous. Without this guard, the greedy max→min
// algorithm can schedule moves that flip which server is most-utilized
// (source becomes min, destination becomes max), producing A→B, B→A
// oscillation within a single detection cycle.
//
// Check: after the move, the destination's utilization must not strictly
// exceed the source's utilization. If it would, no single move can
// improve balance — stop here. This also handles heterogeneous capacity
// correctly by comparing post-move utilization ratios rather than raw
// counts. The integer discretization is handled automatically: when
// counts cannot match the ideal exactly, the check still admits moves
// that reduce the max/min gap without flipping it.
maxCap := serverMaxVolumes[maxServer]
minCap := serverMaxVolumes[minServer]
if allServersHaveMaxInfo && maxCap > 0 && minCap > 0 {
newSrcUtil := float64(effectiveCounts[maxServer]-1) / float64(maxCap)
newDstUtil := float64(effectiveCounts[minServer]+1) / float64(minCap)
if newDstUtil > newSrcUtil {
if len(results) == 0 {
glog.Infof("BALANCE [%s]: No tasks created - no beneficial move available. After move, dest %s util would be %.1f%% vs source %s util %.1f%%",
diskType, minServer, newDstUtil*100, maxServer, newSrcUtil*100)
} else {
glog.Infof("BALANCE [%s]: Created %d task(s), no more beneficial moves available. After move, dest %s util would be %.1f%% vs source %s util %.1f%%",
diskType, len(results), minServer, newDstUtil*100, maxServer, newSrcUtil*100)
}
balanced = true
break
}
}
// Select a volume from the overloaded server using per-server cursor
var selectedVolume *types.VolumeHealthMetrics
serverVols := volumesByServer[maxServer]
cursor := serverCursors[maxServer]
for cursor < len(serverVols) {
metric := serverVols[cursor]
cursor++
// Skip volumes that already have a task in ActiveTopology
if clusterInfo.ActiveTopology != nil && clusterInfo.ActiveTopology.HasAnyTask(metric.VolumeID) {
continue
}
selectedVolume = metric
break
}
serverCursors[maxServer] = cursor
if selectedVolume == nil {
glog.V(1).Infof("BALANCE [%s]: No more eligible volumes on overloaded server %s, trying other servers", diskType, maxServer)
exhaustedServers[maxServer] = true
continue
}
// Skip a destination this specific volume would push over the mark, and
// re-pick, rather than overshoot.
if destinationDiskTooFull(minServer, uint64(selectedVolume.Size)) {
glog.V(1).Infof("BALANCE [%s]: skip destination %s: volume %d (%d bytes) would cross %d%% disk usage",
diskType, minServer, selectedVolume.VolumeID, selectedVolume.Size, balancer.DefaultMaxDiskUsagePercent)
destinationFull[minServer] = true
serverCursors[maxServer]-- // retry this volume against another destination
continue
}
// Create task targeting minServer — the greedy algorithm's natural choice.
// Using minServer instead of letting planBalanceDestination independently
// pick a destination ensures that the detection loop's effective counts
// and the destination selection stay in sync. Without this, the topology's
// LoadCount-based scoring can diverge from the adjustment-based effective
// counts, causing moves to pile onto one server or oscillate (A→B, B→A).
//
// Constrain the destination (including the score-based fallback inside
// createBalanceTask) to servers whose physical disk is not near full;
// sources are unaffected, so a full server can still be drained.
eligibleTargets := make(map[string]int, len(serverVolumeCounts))
for s, c := range serverVolumeCounts {
if !destinationFull[s] && !destinationDiskTooFull(s, 0) {
eligibleTargets[s] = c
}
}
task, destServerID := createBalanceTask(diskType, selectedVolume, clusterInfo, minServer, eligibleTargets, balanceConfig.IoBytePerSecond)
if task == nil {
glog.V(1).Infof("BALANCE [%s]: Cannot plan task for volume %d on server %s, trying next volume", diskType, selectedVolume.VolumeID, maxServer)
continue
}
results = append(results, task)
// Adjust effective counts for the next iteration.
adjustments[maxServer]--
if destServerID != "" {
adjustments[destServerID]++
// Charge the moved volume's bytes to the destination so the disk-fullness
// gate sees it fill up over the course of this cycle.
plannedBytes[destServerID] += uint64(selectedVolume.Size)
// If the destination server wasn't in serverVolumeCounts (e.g., a
// server with 0 volumes not seeded from topology), add it so
// subsequent iterations include it in effective/average/min/max.
if _, exists := serverVolumeCounts[destServerID]; !exists {
serverVolumeCounts[destServerID] = 0
sortedServers = append(sortedServers, destServerID)
sort.Strings(sortedServers)
}
}
}
// Truncated only if we hit maxResults and detection didn't naturally finish
truncated := len(results) >= maxResults && !balanced
return results, truncated
}
// createBalanceTask creates a single balance task for the selected volume.
// targetServer is the server ID chosen by the detection loop's greedy algorithm.
// Returns (nil, "") if destination planning fails.
// On success, returns the task result and the canonical destination server ID.
// allowedServers is the set of servers that passed DC/rack/node filtering in
// the detection loop. When non-empty, the fallback destination planner is
// checked against this set so that filter scope cannot leak.
func createBalanceTask(diskType string, selectedVolume *types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, targetServer string, allowedServers map[string]int, ioBytePerSecond int64) (*types.TaskDetectionResult, string) {
taskID := fmt.Sprintf("balance_vol_%d_%d", selectedVolume.VolumeID, time.Now().UnixNano())
task := &types.TaskDetectionResult{
TaskID: taskID,
TaskType: types.TaskTypeBalance,
VolumeID: selectedVolume.VolumeID,
Server: selectedVolume.Server,
Collection: selectedVolume.Collection,
Priority: types.TaskPriorityNormal,
Reason: fmt.Sprintf("Cluster imbalance detected for %s disk type",
diskType),
ScheduleAt: time.Now(),
}
// Plan destination if ActiveTopology is available
if clusterInfo.ActiveTopology == nil {
glog.Warningf("No ActiveTopology available for destination planning in balance detection")
return nil, ""
}
// Parse replica placement once for use in both scoring and validation.
var volumeRP *super_block.ReplicaPlacement
if selectedVolume.ExpectedReplicas > 0 && selectedVolume.ExpectedReplicas <= 255 {
if parsed, rpErr := super_block.NewReplicaPlacementFromByte(byte(selectedVolume.ExpectedReplicas)); rpErr == nil && parsed.HasReplication() {
volumeRP = parsed
}
}
var replicas []types.ReplicaLocation
if clusterInfo.VolumeReplicaMap != nil {
replicas = clusterInfo.VolumeReplicaMap[selectedVolume.VolumeID]
if volumeRP != nil && len(replicas) == 0 {
glog.V(1).Infof("BALANCE [%s]: No replica locations found for volume %d, skipping placement validation",
diskType, selectedVolume.VolumeID)
}
}
// Resolve the target server chosen by the detection loop's effective counts.
// This keeps destination selection in sync with the greedy algorithm rather
// than relying on topology LoadCount which can diverge across iterations.
destinationPlan, err := resolveBalanceDestination(clusterInfo.ActiveTopology, selectedVolume, targetServer)
if err != nil {
// Fall back to score-based planning if the preferred target can't be resolved
glog.V(1).Infof("BALANCE [%s]: Cannot resolve target %s for volume %d, falling back to score-based planning: %v",
diskType, targetServer, selectedVolume.VolumeID, err)
destinationPlan, err = planBalanceDestination(clusterInfo.ActiveTopology, selectedVolume, volumeRP, replicas, allowedServers)
if err != nil {
glog.Warningf("Failed to plan balance destination for volume %d: %v", selectedVolume.VolumeID, err)
return nil, ""
}
}
// Verify the resolved destination. If it falls outside the filtered scope
// or violates replica placement, fall back to the score-based planner and
// pick the best candidate that is actually valid.
if !isValidBalanceDestination(destinationPlan, allowedServers, volumeRP, replicas, selectedVolume.Server) {
switch {
case destinationPlan == nil:
glog.V(1).Infof("BALANCE [%s]: Planned destination for volume %d is nil, falling back",
diskType, selectedVolume.VolumeID)
case !isAllowedBalanceTarget(destinationPlan.TargetNode, allowedServers):
glog.V(1).Infof("BALANCE [%s]: Planned destination %s for volume %d is outside filtered scope, falling back",
diskType, destinationPlan.TargetNode, selectedVolume.VolumeID)
case volumeRP != nil && len(replicas) > 0:
glog.V(1).Infof("BALANCE [%s]: Destination %s violates replica placement for volume %d (rp=%03d), falling back",
diskType, destinationPlan.TargetNode, selectedVolume.VolumeID, selectedVolume.ExpectedReplicas)
}
destinationPlan, err = planBalanceDestination(clusterInfo.ActiveTopology, selectedVolume, volumeRP, replicas, allowedServers)
if err != nil {
glog.Warningf("BALANCE [%s]: Failed to plan fallback destination for volume %d: %v", diskType, selectedVolume.VolumeID, err)
return nil, ""
}
if !isValidBalanceDestination(destinationPlan, allowedServers, volumeRP, replicas, selectedVolume.Server) {
if destinationPlan == nil {
glog.V(1).Infof("BALANCE [%s]: Fallback destination for volume %d is nil",
diskType, selectedVolume.VolumeID)
} else {
glog.V(1).Infof("BALANCE [%s]: Fallback destination %s is not valid for volume %d",
diskType, destinationPlan.TargetNode, selectedVolume.VolumeID)
}
return nil, ""
}
}
// Find the actual disk containing the volume on the source server
sourceDisk, found := base.FindVolumeDisk(clusterInfo.ActiveTopology, selectedVolume.VolumeID, selectedVolume.Collection, selectedVolume.Server)
if !found {
glog.Warningf("BALANCE [%s]: Could not find volume %d (collection: %s) on source server %s - unable to create balance task",
diskType, selectedVolume.VolumeID, selectedVolume.Collection, selectedVolume.Server)
return nil, ""
}
// Update reason with full details now that we have destination info
task.Reason = fmt.Sprintf("Cluster imbalance detected for %s: move volume %d from %s to %s",
diskType, selectedVolume.VolumeID, selectedVolume.Server, destinationPlan.TargetNode)
// Create typed parameters with unified source and target information
task.TypedParams = &worker_pb.TaskParams{
TaskId: taskID,
VolumeId: selectedVolume.VolumeID,
Collection: selectedVolume.Collection,
VolumeSize: selectedVolume.Size,
Sources: []*worker_pb.TaskSource{
{
Node: selectedVolume.ServerAddress,
DiskId: sourceDisk,
VolumeId: selectedVolume.VolumeID,
EstimatedSize: selectedVolume.Size,
DataCenter: selectedVolume.DataCenter,
Rack: selectedVolume.Rack,
},
},
Targets: []*worker_pb.TaskTarget{
{
Node: destinationPlan.TargetAddress,
DiskId: destinationPlan.TargetDisk,
VolumeId: selectedVolume.VolumeID,
EstimatedSize: destinationPlan.ExpectedSize,
DataCenter: destinationPlan.TargetDC,
Rack: destinationPlan.TargetRack,
},
},
TaskParams: &worker_pb.TaskParams_BalanceParams{
BalanceParams: &worker_pb.BalanceTaskParams{
ForceMove: false,
TimeoutSeconds: 600, // 10 minutes default
IoBytePerSecond: ioBytePerSecond,
},
},
}
glog.V(1).Infof("Planned balance destination for volume %d: %s -> %s",
selectedVolume.VolumeID, selectedVolume.Server, destinationPlan.TargetNode)
// Add pending balance task to ActiveTopology for capacity management
targetDisk := destinationPlan.TargetDisk
err = clusterInfo.ActiveTopology.AddPendingTask(topology.TaskSpec{
TaskID: taskID,
TaskType: topology.TaskTypeBalance,
VolumeID: selectedVolume.VolumeID,
VolumeSize: int64(selectedVolume.Size),
Sources: []topology.TaskSourceSpec{
{ServerID: selectedVolume.Server, DiskID: sourceDisk},
},
Destinations: []topology.TaskDestinationSpec{
{ServerID: destinationPlan.TargetNode, DiskID: targetDisk},
},
})
if err != nil {
glog.Warningf("BALANCE [%s]: Failed to add pending task for volume %d: %v", diskType, selectedVolume.VolumeID, err)
return nil, ""
}
glog.V(2).Infof("Added pending balance task %s to ActiveTopology for volume %d: %s:%d -> %s:%d",
taskID, selectedVolume.VolumeID, selectedVolume.Server, sourceDisk, destinationPlan.TargetNode, targetDisk)
return task, destinationPlan.TargetNode
}
// resolveBalanceDestination resolves the destination for a balance operation
// when the target server is already known (chosen by the detection loop's
// effective volume counts). It finds a disk on the target server that can
// accept another volume after accounting for ALL pending and assigned tasks
// on that disk (via ActiveTopology.GetEffectiveAvailableCapacity) — not the
// static VolumeCount from the topology snapshot. This keeps destination
// planning consistent with the loop's effective-count bookkeeping and
// prevents over-scheduling when multiple moves are planned within the same
// detection cycle.
func resolveBalanceDestination(activeTopology *topology.ActiveTopology, selectedVolume *types.VolumeHealthMetrics, targetServer string) (*topology.DestinationPlan, error) {
nodeDisks := activeTopology.GetNodeDisks(targetServer)
if len(nodeDisks) == 0 {
return nil, fmt.Errorf("target server %s not found in topology", targetServer)
}
var eligibleDisk *topology.DiskInfo
for _, disk := range nodeDisks {
if disk == nil || disk.DiskInfo == nil {
continue
}
if disk.DiskType != selectedVolume.DiskType {
continue
}
// Use effective capacity so that prior moves planned in this same
// detection cycle (already registered as pending tasks) reduce the
// available slots. A disk with VolumeCount << MaxVolumeCount in the
// topology snapshot can still be effectively full if many in-flight
// tasks already target it.
if disk.DiskInfo.MaxVolumeCount > 0 {
available := activeTopology.GetEffectiveAvailableCapacity(disk.NodeID, disk.DiskID)
if available <= 0 {
continue
}
}
eligibleDisk = disk
break
}
if eligibleDisk == nil {
return nil, fmt.Errorf("target server %s has no available disk of type %s", targetServer, selectedVolume.DiskType)
}
targetAddress, err := util.ResolveServerAddress(eligibleDisk.NodeID, activeTopology)
if err != nil {
return nil, fmt.Errorf("failed to resolve address for target server %s: %v", eligibleDisk.NodeID, err)
}
return &topology.DestinationPlan{
TargetNode: eligibleDisk.NodeID,
TargetAddress: targetAddress,
TargetDisk: eligibleDisk.DiskID,
TargetRack: eligibleDisk.Rack,
TargetDC: eligibleDisk.DataCenter,
ExpectedSize: selectedVolume.Size,
}, nil
}
// planBalanceDestination plans the destination for a balance operation using
// score-based selection. Used as a fallback when the preferred target cannot
// be resolved, and for single-move scenarios outside the detection loop.
// rp may be nil when the volume has no replication constraint. When replica
// locations are known, candidates that would violate placement are filtered
// out before scoring.
func planBalanceDestination(activeTopology *topology.ActiveTopology, selectedVolume *types.VolumeHealthMetrics, rp *super_block.ReplicaPlacement, replicas []types.ReplicaLocation, allowedServers map[string]int) (*topology.DestinationPlan, error) {
// Get source node information from topology
var sourceRack, sourceDC string
// Extract rack and DC from topology info
topologyInfo := activeTopology.GetTopologyInfo()
if topologyInfo != nil {
for _, dc := range topologyInfo.DataCenterInfos {
for _, rack := range dc.RackInfos {
for _, dataNodeInfo := range rack.DataNodeInfos {
if dataNodeInfo.Id == selectedVolume.Server {
sourceDC = dc.Id
sourceRack = rack.Id
break
}
}
if sourceRack != "" {
break
}
}
if sourceDC != "" {
break
}
}
}
// Get available disks, excluding the source node
availableDisks := activeTopology.GetAvailableDisks(topology.TaskTypeBalance, selectedVolume.Server)
if len(availableDisks) == 0 {
return nil, fmt.Errorf("no available disks for balance operation")
}
// Sort available disks by NodeID then DiskID for deterministic tie-breaking
sort.Slice(availableDisks, func(i, j int) bool {
if availableDisks[i].NodeID != availableDisks[j].NodeID {
return availableDisks[i].NodeID < availableDisks[j].NodeID
}
return availableDisks[i].DiskID < availableDisks[j].DiskID
})
// Find the best destination disk based on balance criteria
var bestDisk *topology.DiskInfo
bestScore := math.Inf(-1)
for _, disk := range availableDisks {
// Ensure disk type matches
if disk.DiskType != selectedVolume.DiskType {
continue
}
if !isAllowedBalanceTarget(disk.NodeID, allowedServers) {
continue
}
if rp != nil && len(replicas) > 0 {
target := types.ReplicaLocation{
DataCenter: disk.DataCenter,
Rack: disk.Rack,
NodeID: disk.NodeID,
Host: hostFromAddress(disk.Address, disk.NodeID),
}
if !IsGoodMove(rp, replicas, selectedVolume.Server, target) {
continue
}
}
score := calculateBalanceScore(disk, sourceRack, sourceDC, selectedVolume.Size, rp)
if score > bestScore {
bestScore = score
bestDisk = disk
}
}
if bestDisk == nil {
return nil, fmt.Errorf("no suitable destination found for balance operation")
}
// Get the target server address
targetAddress, err := util.ResolveServerAddress(bestDisk.NodeID, activeTopology)
if err != nil {
return nil, fmt.Errorf("failed to resolve address for target server %s: %v", bestDisk.NodeID, err)
}
return &topology.DestinationPlan{
TargetNode: bestDisk.NodeID,
TargetAddress: targetAddress,
TargetDisk: bestDisk.DiskID,
TargetRack: bestDisk.Rack,
TargetDC: bestDisk.DataCenter,
ExpectedSize: selectedVolume.Size,
PlacementScore: bestScore,
}, nil
}
// calculateBalanceScore calculates placement score for balance operations.
// LoadCount reflects pending+assigned tasks on the disk, so we factor it into
// the utilization estimate to avoid stacking multiple moves onto the same target.
// rp may be nil when the volume has no replication constraint; in that case the
// scorer defaults to preferring cross-rack/DC distribution.
func calculateBalanceScore(disk *topology.DiskInfo, sourceRack, sourceDC string, volumeSize uint64, rp *super_block.ReplicaPlacement) float64 {
if disk.DiskInfo == nil {
return 0.0
}
score := 0.0
// Prefer disks with lower effective volume count (current + pending moves).
// LoadCount is included so that disks already targeted by planned moves
// appear more utilized, naturally spreading work across targets.
if disk.DiskInfo.MaxVolumeCount > 0 {
effectiveVolumeCount := float64(disk.DiskInfo.VolumeCount) + float64(disk.LoadCount)
utilization := effectiveVolumeCount / float64(disk.DiskInfo.MaxVolumeCount)
score += (1.0 - utilization) * 50.0 // Up to 50 points for low utilization
}
// Rack scoring: respect the replication policy.
// If replicas must stay on the same rack (SameRackCount > 0 with no
// cross-rack requirement), prefer same-rack destinations. Otherwise
// prefer different racks for better distribution.
sameRack := disk.Rack == sourceRack
if rp != nil && rp.DiffRackCount == 0 && rp.SameRackCount > 0 {
if sameRack {
score += 30.0
}
} else {
if !sameRack {
score += 30.0
}
}
// DC scoring: same idea. If the policy requires all copies in one DC,
// prefer same-DC destinations. Otherwise prefer different DCs.
sameDC := disk.DataCenter == sourceDC
if rp != nil && rp.DiffDataCenterCount == 0 && (rp.SameRackCount > 0 || rp.DiffRackCount > 0) {
if sameDC {
score += 20.0
}
} else {
if !sameDC {
score += 20.0
}
}
return score
}
func isAllowedBalanceTarget(nodeID string, allowedServers map[string]int) bool {
if len(allowedServers) == 0 {
return true
}
_, ok := allowedServers[nodeID]
return ok
}
func isValidBalanceDestination(plan *topology.DestinationPlan, allowedServers map[string]int, rp *super_block.ReplicaPlacement, replicas []types.ReplicaLocation, sourceNodeID string) bool {
if plan == nil {
return false
}
if !isAllowedBalanceTarget(plan.TargetNode, allowedServers) {
return false
}
if rp == nil || len(replicas) == 0 {
return true
}
target := types.ReplicaLocation{
DataCenter: plan.TargetDC,
Rack: plan.TargetRack,
NodeID: plan.TargetNode,
Host: hostFromAddress(plan.TargetAddress, plan.TargetNode),
}
return IsGoodMove(rp, replicas, sourceNodeID, target)
}
// parseCSVSet splits a comma-separated string into a set of trimmed, non-empty values.

View File

@@ -0,0 +1,513 @@
package balance
import (
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
const (
fullnessThreshold = 1.01
stateActive = "ACTIVE"
stateFull = "FULL"
)
// filterByState filters metrics by volume state for testing.
func filterByState(metrics []*types.VolumeHealthMetrics, state string) []*types.VolumeHealthMetrics {
if state != stateActive && state != stateFull {
return metrics
}
var out []*types.VolumeHealthMetrics
for _, m := range metrics {
if m == nil {
continue
}
if state == stateActive && m.FullnessRatio < fullnessThreshold {
out = append(out, m)
}
if state == stateFull && m.FullnessRatio >= fullnessThreshold {
out = append(out, m)
}
}
return out
}
// Integration tests that exercise multiple features together:
// DC/rack/node filters, volume state filtering, replica placement validation,
// and collection scoping all interacting within a single detection run.
// makeVolumesWithOptions generates metrics with additional options.
type volumeOption func(m *types.VolumeHealthMetrics)
func withFullness(ratio float64) volumeOption {
return func(m *types.VolumeHealthMetrics) { m.FullnessRatio = ratio }
}
func withReplicas(rp int) volumeOption {
return func(m *types.VolumeHealthMetrics) { m.ExpectedReplicas = rp }
}
func makeVolumesWith(server, diskType, dc, rack, collection string, volumeIDBase uint32, n int, opts ...volumeOption) []*types.VolumeHealthMetrics {
vols := makeVolumes(server, diskType, dc, rack, collection, volumeIDBase, n)
for _, v := range vols {
for _, opt := range opts {
opt(v)
}
}
return vols
}
// buildReplicaMap builds a VolumeReplicaMap from metrics (each metric is one replica location).
func buildReplicaMap(metrics []*types.VolumeHealthMetrics) map[uint32][]types.ReplicaLocation {
m := make(map[uint32][]types.ReplicaLocation)
for _, metric := range metrics {
m[metric.VolumeID] = append(m[metric.VolumeID], types.ReplicaLocation{
DataCenter: metric.DataCenter,
Rack: metric.Rack,
NodeID: metric.Server,
})
}
return m
}
// TestIntegration_DCFilterWithVolumeState tests that DC filtering and volume
// state filtering compose correctly: only ACTIVE volumes in the specified DC
// participate in balancing.
func TestIntegration_DCFilterWithVolumeState(t *testing.T) {
servers := []serverSpec{
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc2", rack: "rack1"},
}
var allMetrics []*types.VolumeHealthMetrics
// dc1: node-a has 40 active volumes, node-b has 10 active volumes
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 40, withFullness(0.5))...)
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "c1", 100, 10, withFullness(0.5))...)
// dc1: node-a also has 20 FULL volumes that should be excluded
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 200, 20, withFullness(1.5))...)
// dc2: node-c has 50 active volumes (should be excluded by DC filter)
allMetrics = append(allMetrics, makeVolumesWith("node-c", "hdd", "dc2", "rack1", "c1", 300, 50, withFullness(0.5))...)
// Apply volume state filter (ACTIVE only) first
activeMetrics := filterByState(allMetrics, stateActive)
// Then apply DC filter
dcMetrics := make([]*types.VolumeHealthMetrics, 0)
for _, m := range activeMetrics {
if m.DataCenter == "dc1" {
dcMetrics = append(dcMetrics, m)
}
}
at := buildTopology(servers, allMetrics)
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
conf := defaultConf()
conf.DataCenterFilter = "dc1"
tasks, _, err := Detection(dcMetrics, clusterInfo, conf, 100)
if err != nil {
t.Fatalf("Detection failed: %v", err)
}
if len(tasks) == 0 {
t.Fatal("Expected balance tasks for 40/10 active-only imbalance in dc1, got 0")
}
for _, task := range tasks {
if task.Server == "node-c" {
t.Error("node-c (dc2) should not be a source")
}
if task.TypedParams != nil {
for _, tgt := range task.TypedParams.Targets {
if strings.Contains(tgt.Node, "node-c") {
t.Error("node-c (dc2) should not be a target")
}
}
}
}
// Verify convergence uses only the 50 active dc1 volumes (40+10),
// not the 20 full volumes
effective := computeEffectiveCounts(servers[:2], dcMetrics, tasks)
total := 0
maxC, minC := 0, len(dcMetrics)
for _, c := range effective {
total += c
if c > maxC {
maxC = c
}
if c < minC {
minC = c
}
}
if total != 50 {
t.Errorf("Expected 50 total active volumes in dc1, got %d", total)
}
t.Logf("DC+state filter: %d tasks, effective=%v", len(tasks), effective)
}
// TestIntegration_NodeFilterWithCollections tests that node filtering works
// correctly when volumes span multiple collections.
func TestIntegration_NodeFilterWithCollections(t *testing.T) {
servers := []serverSpec{
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1"},
}
var allMetrics []*types.VolumeHealthMetrics
// node-a: 30 "photos" + 20 "videos" = 50 total
allMetrics = append(allMetrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "photos", 1, 30)...)
allMetrics = append(allMetrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "videos", 100, 20)...)
// node-b: 5 "photos" + 5 "videos" = 10 total
allMetrics = append(allMetrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "photos", 200, 5)...)
allMetrics = append(allMetrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "videos", 300, 5)...)
// node-c: 40 volumes (should be excluded by node filter)
allMetrics = append(allMetrics, makeVolumes("node-c", "hdd", "dc1", "rack1", "photos", 400, 40)...)
// Apply node filter
filteredMetrics := make([]*types.VolumeHealthMetrics, 0)
for _, m := range allMetrics {
if m.Server == "node-a" || m.Server == "node-b" {
filteredMetrics = append(filteredMetrics, m)
}
}
at := buildTopology(servers, allMetrics)
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
conf := defaultConf()
conf.NodeFilter = "node-a,node-b"
tasks, _, err := Detection(filteredMetrics, clusterInfo, conf, 100)
if err != nil {
t.Fatalf("Detection failed: %v", err)
}
if len(tasks) == 0 {
t.Fatal("Expected tasks for 50/10 imbalance within node-a,node-b")
}
// All moves should be between node-a and node-b only
for _, task := range tasks {
if task.Server != "node-a" && task.Server != "node-b" {
t.Errorf("Source %s should be node-a or node-b", task.Server)
}
if task.TypedParams != nil {
for _, tgt := range task.TypedParams.Targets {
if !strings.Contains(tgt.Node, "node-a") && !strings.Contains(tgt.Node, "node-b") {
t.Errorf("Target %s should be node-a or node-b", tgt.Node)
}
}
}
}
assertNoDuplicateVolumes(t, tasks)
t.Logf("Node filter with mixed collections: %d tasks", len(tasks))
}
// TestIntegration_ReplicaPlacementWithDCFilter tests that replica placement
// validation prevents moves that would violate replication policy even when
// DC filtering restricts the available servers.
func TestIntegration_ReplicaPlacementWithDCFilter(t *testing.T) {
// Setup: 2 DCs, volumes with rp=100 (1 replica in different DC)
// DC filter restricts to dc1 only.
// Replicas: each volume has one copy in dc1 and one in dc2.
// The balancer should NOT move volumes within dc1 to a node that would
// violate the cross-DC placement requirement.
servers := []serverSpec{
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc2", rack: "rack1"},
}
// node-a: 30 volumes with rp=100, node-b: 5 volumes with rp=100
// Each volume also has a replica on node-c (dc2)
var allMetrics []*types.VolumeHealthMetrics
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 30, withReplicas(100))...)
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "c1", 100, 5, withReplicas(100))...)
// dc2 replicas (not part of filtered metrics, but in replica map)
dc2Replicas := makeVolumesWith("node-c", "hdd", "dc2", "rack1", "c1", 1, 30, withReplicas(100))
// Build replica map: volumes 1-30 have replicas on node-a AND node-c
replicaMap := buildReplicaMap(allMetrics)
for _, r := range dc2Replicas {
replicaMap[r.VolumeID] = append(replicaMap[r.VolumeID], types.ReplicaLocation{
DataCenter: r.DataCenter,
Rack: r.Rack,
NodeID: r.Server,
})
}
// Filter to dc1 only
dc1Metrics := make([]*types.VolumeHealthMetrics, 0)
for _, m := range allMetrics {
if m.DataCenter == "dc1" {
dc1Metrics = append(dc1Metrics, m)
}
}
at := buildTopology(servers, allMetrics)
clusterInfo := &types.ClusterInfo{
ActiveTopology: at,
VolumeReplicaMap: replicaMap,
}
conf := defaultConf()
conf.DataCenterFilter = "dc1"
tasks, _, err := Detection(dc1Metrics, clusterInfo, conf, 100)
if err != nil {
t.Fatalf("Detection failed: %v", err)
}
// With rp=100, moving a volume from node-a to node-b is valid because
// the cross-DC replica on node-c is preserved. The balancer should
// produce moves that keep the dc2 replica intact.
if len(tasks) == 0 {
t.Fatal("Expected tasks: 30/5 imbalance with valid cross-DC replicas")
}
for _, task := range tasks {
// All sources should be from dc1
if task.Server != "node-a" && task.Server != "node-b" {
t.Errorf("Source %s should be in dc1", task.Server)
}
// All targets should be in dc1
if task.TypedParams != nil {
for _, tgt := range task.TypedParams.Targets {
if strings.Contains(tgt.Node, "node-c") {
t.Errorf("Target should not be node-c (dc2) with dc1 filter")
}
}
}
}
assertNoDuplicateVolumes(t, tasks)
t.Logf("Replica placement + DC filter: %d tasks", len(tasks))
}
// TestIntegration_RackFilterWithReplicaPlacement tests that rack filtering
// and replica placement validation (rp=010) work together. With rp=010,
// replicas must be on different racks. When rack filtering restricts to one
// rack, the balancer should still produce valid moves within that rack for
// volumes whose replicas satisfy the cross-rack requirement via nodes outside
// the filter.
func TestIntegration_RackFilterWithReplicaPlacement(t *testing.T) {
servers := []serverSpec{
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack2"},
}
// rack1: node-a has 30 volumes, node-b has 5
// Each volume also has a replica on node-c (rack2), satisfying rp=010
var allMetrics []*types.VolumeHealthMetrics
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 30, withReplicas(10))...)
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "c1", 100, 5, withReplicas(10))...)
rack2Replicas := makeVolumesWith("node-c", "hdd", "dc1", "rack2", "c1", 1, 30, withReplicas(10))
replicaMap := buildReplicaMap(allMetrics)
for _, r := range rack2Replicas {
replicaMap[r.VolumeID] = append(replicaMap[r.VolumeID], types.ReplicaLocation{
DataCenter: r.DataCenter,
Rack: r.Rack,
NodeID: r.Server,
})
}
// Filter to rack1 only
rack1Metrics := make([]*types.VolumeHealthMetrics, 0)
for _, m := range allMetrics {
if m.Rack == "rack1" {
rack1Metrics = append(rack1Metrics, m)
}
}
at := buildTopology(servers, allMetrics)
clusterInfo := &types.ClusterInfo{
ActiveTopology: at,
VolumeReplicaMap: replicaMap,
}
conf := defaultConf()
conf.RackFilter = "rack1"
tasks, _, err := Detection(rack1Metrics, clusterInfo, conf, 100)
if err != nil {
t.Fatalf("Detection failed: %v", err)
}
// Moving within rack1 (node-a → node-b) is valid because the cross-rack
// replica on node-c (rack2) is preserved.
if len(tasks) == 0 {
t.Fatal("Expected tasks for 30/5 imbalance within rack1")
}
for _, task := range tasks {
if task.Server == "node-c" {
t.Error("node-c (rack2) should not be a source with rack1 filter")
}
if task.TypedParams != nil {
for _, tgt := range task.TypedParams.Targets {
if strings.Contains(tgt.Node, "node-c") {
t.Error("node-c (rack2) should not be a target with rack1 filter")
}
}
}
}
assertNoDuplicateVolumes(t, tasks)
t.Logf("Rack filter + replica placement: %d tasks", len(tasks))
}
// TestIntegration_AllFactors exercises all filtering dimensions simultaneously:
// DC filter, rack filter, volume state filter, replica placement validation,
// and mixed collections.
func TestIntegration_AllFactors(t *testing.T) {
servers := []serverSpec{
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack2"}, // excluded by rack filter
{id: "node-d", diskType: "hdd", diskID: 4, dc: "dc2", rack: "rack1"}, // excluded by DC filter
}
var allMetrics []*types.VolumeHealthMetrics
// node-a: 25 active "photos" + 10 full "photos" (full excluded by state filter)
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "photos", 1, 25, withFullness(0.5), withReplicas(100))...)
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "photos", 200, 10, withFullness(1.5), withReplicas(100))...)
// node-b: 5 active "photos"
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "photos", 100, 5, withFullness(0.5), withReplicas(100))...)
// node-c: 20 volumes in dc1/rack2 (excluded by rack filter)
allMetrics = append(allMetrics, makeVolumesWith("node-c", "hdd", "dc1", "rack2", "photos", 300, 20, withFullness(0.5))...)
// node-d: 30 volumes in dc2 (excluded by DC filter, but provides cross-DC replicas)
dc2Replicas := makeVolumesWith("node-d", "hdd", "dc2", "rack1", "photos", 1, 25, withFullness(0.5), withReplicas(100))
// Build replica map: volumes 1-25 have cross-DC replicas on node-d
replicaMap := buildReplicaMap(allMetrics)
for _, r := range dc2Replicas {
replicaMap[r.VolumeID] = append(replicaMap[r.VolumeID], types.ReplicaLocation{
DataCenter: r.DataCenter,
Rack: r.Rack,
NodeID: r.Server,
})
}
// Apply all filters: ACTIVE state, dc1, rack1
filtered := filterByState(allMetrics, stateActive)
var finalMetrics []*types.VolumeHealthMetrics
for _, m := range filtered {
if m.DataCenter == "dc1" && m.Rack == "rack1" {
finalMetrics = append(finalMetrics, m)
}
}
at := buildTopology(servers, allMetrics)
clusterInfo := &types.ClusterInfo{
ActiveTopology: at,
VolumeReplicaMap: replicaMap,
}
conf := defaultConf()
conf.DataCenterFilter = "dc1"
conf.RackFilter = "rack1"
tasks, _, err := Detection(finalMetrics, clusterInfo, conf, 100)
if err != nil {
t.Fatalf("Detection failed: %v", err)
}
if len(tasks) == 0 {
t.Fatal("Expected tasks for 25/5 active imbalance in dc1/rack1")
}
// Verify all moves stay within dc1/rack1 scope
for i, task := range tasks {
if task.Server != "node-a" && task.Server != "node-b" {
t.Errorf("Task %d: source %s should be node-a or node-b", i, task.Server)
}
if task.TypedParams != nil {
for _, tgt := range task.TypedParams.Targets {
node := tgt.Node
if !strings.Contains(node, "node-a") && !strings.Contains(node, "node-b") {
t.Errorf("Task %d: target %s should be node-a or node-b", i, node)
}
}
}
}
assertNoDuplicateVolumes(t, tasks)
// Verify convergence
effective := computeEffectiveCounts(servers[:2], finalMetrics, tasks)
total := 0
maxC, minC := 0, len(finalMetrics)
for _, c := range effective {
total += c
if c > maxC {
maxC = c
}
if c < minC {
minC = c
}
}
// Should have balanced the 30 active dc1/rack1 volumes (25+5)
if total != 30 {
t.Errorf("Expected 30 total filtered volumes, got %d", total)
}
avg := float64(total) / float64(len(effective))
imbalance := float64(maxC-minC) / avg
if imbalance > conf.ImbalanceThreshold {
t.Errorf("Still imbalanced: effective=%v, imbalance=%.1f%% (threshold=%.1f%%)",
effective, imbalance*100, conf.ImbalanceThreshold*100)
}
t.Logf("All factors combined: %d tasks, effective=%v", len(tasks), effective)
}
// TestIntegration_FullVolumesOnlyBalancing verifies that with FULL state filter,
// only full volumes participate in balancing.
func TestIntegration_FullVolumesOnlyBalancing(t *testing.T) {
servers := []serverSpec{
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
}
var allMetrics []*types.VolumeHealthMetrics
// node-a: 10 active + 30 full
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 10, withFullness(0.5))...)
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 100, 30, withFullness(1.5))...)
// node-b: 10 active + 5 full
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "c1", 200, 10, withFullness(0.5))...)
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "c1", 300, 5, withFullness(1.5))...)
// Filter to FULL only
fullMetrics := filterByState(allMetrics, stateFull)
at := buildTopology(servers, allMetrics)
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
tasks, _, err := Detection(fullMetrics, clusterInfo, defaultConf(), 100)
if err != nil {
t.Fatalf("Detection failed: %v", err)
}
if len(tasks) == 0 {
t.Fatal("Expected tasks for 30/5 full volume imbalance")
}
// Verify only full volumes are moved (IDs 100-129 from node-a, 300-304 from node-b)
for _, task := range tasks {
vid := task.VolumeID
// Active volumes are IDs 1-10 (node-a) and 200-209 (node-b)
if (vid >= 1 && vid <= 10) || (vid >= 200 && vid <= 209) {
t.Errorf("Task moved active volume %d, should only move full volumes", vid)
}
}
assertNoDuplicateVolumes(t, tasks)
t.Logf("Full-only balancing: %d tasks", len(tasks))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
package balance
import (
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// TypedTask implements balance operation with typed protobuf parameters
type TypedTask struct {
*base.BaseTypedTask
// Task state from protobuf
sourceServer string
destNode string
volumeID uint32
collection string
estimatedSize uint64
forceMove bool
timeoutSeconds int32
}
// NewTypedTask creates a new typed balance task
func NewTypedTask() types.TypedTaskInterface {
task := &TypedTask{
BaseTypedTask: base.NewBaseTypedTask(types.TaskTypeBalance),
}
return task
}
// ValidateTyped validates the typed parameters for balance task
func (t *TypedTask) ValidateTyped(params *worker_pb.TaskParams) error {
// Basic validation from base class
if err := t.BaseTypedTask.ValidateTyped(params); err != nil {
return err
}
// Check that we have balance-specific parameters
balanceParams := params.GetBalanceParams()
if balanceParams == nil {
return fmt.Errorf("balance_params is required for balance task")
}
// Validate sources and targets
if len(params.Sources) == 0 {
return fmt.Errorf("at least one source is required for balance task")
}
if len(params.Targets) == 0 {
return fmt.Errorf("at least one target is required for balance task")
}
// Validate that source and target have volume IDs
if params.Sources[0].VolumeId == 0 {
return fmt.Errorf("source volume_id is required for balance task")
}
if params.Targets[0].VolumeId == 0 {
return fmt.Errorf("target volume_id is required for balance task")
}
// Validate timeout
if balanceParams.TimeoutSeconds <= 0 {
return fmt.Errorf("timeout_seconds must be greater than 0")
}
return nil
}
// EstimateTimeTyped estimates the time needed for balance operation based on protobuf parameters
func (t *TypedTask) EstimateTimeTyped(params *worker_pb.TaskParams) time.Duration {
balanceParams := params.GetBalanceParams()
if balanceParams != nil {
// Use the timeout from parameters if specified
if balanceParams.TimeoutSeconds > 0 {
return time.Duration(balanceParams.TimeoutSeconds) * time.Second
}
}
// Estimate based on volume size from sources (1 minute per GB)
if len(params.Sources) > 0 {
source := params.Sources[0]
if source.EstimatedSize > 0 {
gbSize := source.EstimatedSize / (1024 * 1024 * 1024)
return time.Duration(gbSize) * time.Minute
}
}
// Default estimation
return 10 * time.Minute
}
// ExecuteTyped implements the balance operation with typed parameters
func (t *TypedTask) ExecuteTyped(params *worker_pb.TaskParams) error {
// Extract basic parameters
t.volumeID = params.VolumeId
t.collection = params.Collection
// Ensure sources and targets are present (should be guaranteed by validation)
if len(params.Sources) == 0 {
return fmt.Errorf("at least one source is required for balance task (ExecuteTyped)")
}
if len(params.Targets) == 0 {
return fmt.Errorf("at least one target is required for balance task (ExecuteTyped)")
}
// Extract source and target information
t.sourceServer = params.Sources[0].Node
t.estimatedSize = params.Sources[0].EstimatedSize
t.destNode = params.Targets[0].Node
// Extract balance-specific parameters
balanceParams := params.GetBalanceParams()
if balanceParams != nil {
t.forceMove = balanceParams.ForceMove
t.timeoutSeconds = balanceParams.TimeoutSeconds
}
glog.Infof("Starting typed balance task for volume %d: %s -> %s (collection: %s, size: %d bytes)",
t.volumeID, t.sourceServer, t.destNode, t.collection, t.estimatedSize)
// Simulate balance operation with progress updates
steps := []struct {
name string
duration time.Duration
progress float64
}{
{"Analyzing cluster state", 2 * time.Second, 15},
{"Verifying destination capacity", 1 * time.Second, 25},
{"Starting volume migration", 1 * time.Second, 35},
{"Moving volume data", 6 * time.Second, 75},
{"Updating cluster metadata", 2 * time.Second, 95},
{"Verifying balance completion", 1 * time.Second, 100},
}
for _, step := range steps {
if t.IsCancelled() {
return fmt.Errorf("balance task cancelled during: %s", step.name)
}
glog.V(1).Infof("Balance task step: %s", step.name)
t.SetProgress(step.progress)
// Simulate work
time.Sleep(step.duration)
}
glog.Infof("Typed balance task completed successfully for volume %d: %s -> %s",
t.volumeID, t.sourceServer, t.destNode)
return nil
}
// Register the typed task in the global registry
func init() {
types.RegisterGlobalTypedTask(types.TaskTypeBalance, NewTypedTask)
glog.V(1).Infof("Registered typed balance task")
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,828 @@
package balance
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
"google.golang.org/protobuf/proto"
)
func TestDecodeVolumeBalanceTaskParamsFromPayload(t *testing.T) {
expected := &worker_pb.TaskParams{
TaskId: "task-1",
VolumeId: 42,
Collection: "photos",
Sources: []*worker_pb.TaskSource{
{
Node: "10.0.0.1:8080",
VolumeId: 42,
},
},
Targets: []*worker_pb.TaskTarget{
{
Node: "10.0.0.2:8080",
VolumeId: 42,
},
},
TaskParams: &worker_pb.TaskParams_BalanceParams{
BalanceParams: &worker_pb.BalanceTaskParams{
ForceMove: true,
TimeoutSeconds: 1200,
},
},
}
payload, err := proto.Marshal(expected)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
job := &plugin_pb.JobSpec{
JobId: "job-from-admin",
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
},
}
actual, err := decodeVolumeBalanceTaskParams(job)
if err != nil {
t.Fatalf("decodeVolumeBalanceTaskParams() err = %v", err)
}
if !proto.Equal(expected, actual) {
t.Fatalf("decoded params mismatch\nexpected: %+v\nactual: %+v", expected, actual)
}
}
func TestDecodeVolumeBalanceTaskParamsFallback(t *testing.T) {
job := &plugin_pb.JobSpec{
JobId: "job-2",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7}},
"source_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "127.0.0.1:8080"}},
"target_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "127.0.0.2:8080"}},
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "videos"}},
},
}
params, err := decodeVolumeBalanceTaskParams(job)
if err != nil {
t.Fatalf("decodeVolumeBalanceTaskParams() err = %v", err)
}
if params.TaskId != "job-2" || params.VolumeId != 7 || params.Collection != "videos" {
t.Fatalf("unexpected basic params: %+v", params)
}
if len(params.Sources) != 1 || params.Sources[0].Node != "127.0.0.1:8080" {
t.Fatalf("unexpected sources: %+v", params.Sources)
}
if len(params.Targets) != 1 || params.Targets[0].Node != "127.0.0.2:8080" {
t.Fatalf("unexpected targets: %+v", params.Targets)
}
if params.GetBalanceParams() == nil {
t.Fatalf("expected fallback balance params")
}
}
func TestDeriveBalanceWorkerConfig(t *testing.T) {
values := map[string]*plugin_pb.ConfigValue{
"imbalance_threshold": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.45},
},
"min_server_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 5},
},
}
cfg := deriveBalanceWorkerConfig(values)
if cfg.TaskConfig.ImbalanceThreshold != 0.45 {
t.Fatalf("expected imbalance_threshold 0.45, got %v", cfg.TaskConfig.ImbalanceThreshold)
}
if cfg.TaskConfig.MinServerCount != 5 {
t.Fatalf("expected min_server_count 5, got %d", cfg.TaskConfig.MinServerCount)
}
// Defaults for batch config when not specified
if cfg.MaxConcurrentMoves != defaultMaxConcurrentMoves {
t.Fatalf("expected default max_concurrent_moves %d, got %d", defaultMaxConcurrentMoves, cfg.MaxConcurrentMoves)
}
if cfg.BatchSize != 20 {
t.Fatalf("expected default batch_size 20, got %d", cfg.BatchSize)
}
}
func TestDeriveBalanceWorkerConfigBatchFields(t *testing.T) {
values := map[string]*plugin_pb.ConfigValue{
"max_concurrent_moves": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 10},
},
"batch_size": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 50},
},
}
cfg := deriveBalanceWorkerConfig(values)
if cfg.MaxConcurrentMoves != 10 {
t.Fatalf("expected max_concurrent_moves 10, got %d", cfg.MaxConcurrentMoves)
}
if cfg.BatchSize != 50 {
t.Fatalf("expected batch_size 50, got %d", cfg.BatchSize)
}
}
func TestDeriveBalanceWorkerConfigBatchClamping(t *testing.T) {
values := map[string]*plugin_pb.ConfigValue{
"max_concurrent_moves": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 999},
},
"batch_size": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0},
},
}
cfg := deriveBalanceWorkerConfig(values)
if cfg.MaxConcurrentMoves != 50 {
t.Fatalf("expected max_concurrent_moves clamped to 50, got %d", cfg.MaxConcurrentMoves)
}
if cfg.BatchSize != 1 {
t.Fatalf("expected batch_size clamped to 1, got %d", cfg.BatchSize)
}
}
func makeDetectionResult(volumeID uint32, source, target, collection string) *workertypes.TaskDetectionResult {
return &workertypes.TaskDetectionResult{
TaskID: fmt.Sprintf("balance-%d", volumeID),
TaskType: workertypes.TaskTypeBalance,
VolumeID: volumeID,
Server: source,
Collection: collection,
Priority: workertypes.TaskPriorityNormal,
Reason: "imbalanced",
TypedParams: &worker_pb.TaskParams{
VolumeId: volumeID,
Collection: collection,
VolumeSize: 1024,
Sources: []*worker_pb.TaskSource{
{Node: source, VolumeId: volumeID},
},
Targets: []*worker_pb.TaskTarget{
{Node: target, VolumeId: volumeID},
},
TaskParams: &worker_pb.TaskParams_BalanceParams{
BalanceParams: &worker_pb.BalanceTaskParams{TimeoutSeconds: 600},
},
},
}
}
func TestBuildBatchVolumeBalanceProposals_SingleBatch(t *testing.T) {
results := []*workertypes.TaskDetectionResult{
makeDetectionResult(1, "s1:8080", "t1:8080", "c1"),
makeDetectionResult(2, "s2:8080", "t2:8080", "c1"),
makeDetectionResult(3, "s1:8080", "t2:8080", "c1"),
}
proposals := buildBatchVolumeBalanceProposals(results, 10, 5)
if len(proposals) != 1 {
t.Fatalf("expected 1 batch proposal, got %d", len(proposals))
}
p := proposals[0]
if p.Labels["batch"] != "true" {
t.Fatalf("expected batch label")
}
if p.Labels["batch_size"] != "3" {
t.Fatalf("expected batch_size label '3', got %q", p.Labels["batch_size"])
}
// Decode and verify moves
payload := p.Parameters["task_params_pb"].GetBytesValue()
if len(payload) == 0 {
t.Fatalf("expected task_params_pb payload")
}
decoded := &worker_pb.TaskParams{}
if err := proto.Unmarshal(payload, decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
moves := decoded.GetBalanceParams().GetMoves()
if len(moves) != 3 {
t.Fatalf("expected 3 moves, got %d", len(moves))
}
if moves[0].VolumeId != 1 || moves[1].VolumeId != 2 || moves[2].VolumeId != 3 {
t.Fatalf("unexpected volume IDs: %v", moves)
}
if decoded.GetBalanceParams().MaxConcurrentMoves != 5 {
t.Fatalf("expected MaxConcurrentMoves 5, got %d", decoded.GetBalanceParams().MaxConcurrentMoves)
}
}
func TestBuildBatchVolumeBalanceProposals_MultipleBatches(t *testing.T) {
results := make([]*workertypes.TaskDetectionResult, 5)
for i := range results {
results[i] = makeDetectionResult(uint32(i+1), "s1:8080", "t1:8080", "c1")
}
proposals := buildBatchVolumeBalanceProposals(results, 2, 3)
// 5 results / batch_size 2 = 3 proposals (2, 2, 1)
if len(proposals) != 3 {
t.Fatalf("expected 3 proposals, got %d", len(proposals))
}
// First two should be batch proposals
if proposals[0].Labels["batch"] != "true" {
t.Fatalf("first proposal should be batch")
}
if proposals[1].Labels["batch"] != "true" {
t.Fatalf("second proposal should be batch")
}
// Last one has only 1 result, should fall back to single-move proposal
if proposals[2].Labels["batch"] == "true" {
t.Fatalf("last proposal with 1 result should be single-move, not batch")
}
}
func TestBuildBatchVolumeBalanceProposals_BatchSizeOne(t *testing.T) {
results := []*workertypes.TaskDetectionResult{
makeDetectionResult(1, "s1:8080", "t1:8080", "c1"),
makeDetectionResult(2, "s2:8080", "t2:8080", "c1"),
}
// batch_size=1 should not be called (Detect guards this), but test the function directly
proposals := buildBatchVolumeBalanceProposals(results, 1, 5)
// Each result becomes its own single-move proposal
if len(proposals) != 2 {
t.Fatalf("expected 2 proposals, got %d", len(proposals))
}
}
func TestVolumeBalanceDescriptorHasBatchFields(t *testing.T) {
descriptor := NewVolumeBalanceHandler(nil).Descriptor()
if !workerConfigFormHasField(descriptor.WorkerConfigForm, "max_concurrent_moves") {
t.Fatalf("expected max_concurrent_moves in worker config form")
}
if !workerConfigFormHasField(descriptor.WorkerConfigForm, "batch_size") {
t.Fatalf("expected batch_size in worker config form")
}
}
func TestBuildVolumeBalanceProposal(t *testing.T) {
params := &worker_pb.TaskParams{
TaskId: "balance-task-1",
VolumeId: 55,
Collection: "images",
Sources: []*worker_pb.TaskSource{
{
Node: "source-a:8080",
VolumeId: 55,
},
},
Targets: []*worker_pb.TaskTarget{
{
Node: "target-b:8080",
VolumeId: 55,
},
},
TaskParams: &worker_pb.TaskParams_BalanceParams{
BalanceParams: &worker_pb.BalanceTaskParams{
TimeoutSeconds: 600,
},
},
}
result := &workertypes.TaskDetectionResult{
TaskID: "balance-task-1",
TaskType: workertypes.TaskTypeBalance,
VolumeID: 55,
Server: "source-a",
Collection: "images",
Priority: workertypes.TaskPriorityHigh,
Reason: "imbalanced load",
TypedParams: params,
}
proposal, err := buildVolumeBalanceProposal(result)
if err != nil {
t.Fatalf("buildVolumeBalanceProposal() err = %v", err)
}
if proposal.JobType != "volume_balance" {
t.Fatalf("unexpected job type %q", proposal.JobType)
}
if proposal.DedupeKey == "" {
t.Fatalf("expected dedupe key")
}
if proposal.Parameters["task_params_pb"] == nil {
t.Fatalf("expected serialized task params")
}
if proposal.Labels["source_node"] != "source-a:8080" {
t.Fatalf("unexpected source label %q", proposal.Labels["source_node"])
}
if proposal.Labels["target_node"] != "target-b:8080" {
t.Fatalf("unexpected target label %q", proposal.Labels["target_node"])
}
}
func TestVolumeBalanceHandlerRejectsUnsupportedJobType(t *testing.T) {
handler := NewVolumeBalanceHandler(nil)
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: "vacuum",
}, noopDetectionSender{})
if err == nil {
t.Fatalf("expected detect job type mismatch error")
}
err = handler.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
Job: &plugin_pb.JobSpec{JobId: "job-1", JobType: "vacuum"},
}, noopExecutionSender{})
if err == nil {
t.Fatalf("expected execute job type mismatch error")
}
}
func TestEmitVolumeBalanceDetectionDecisionTraceNoTasks(t *testing.T) {
sender := &recordingDetectionSender{}
config := NewDefaultConfig()
config.ImbalanceThreshold = 0.2
config.MinServerCount = 2
metrics := []*workertypes.VolumeHealthMetrics{
{VolumeID: 1, Server: "server-a", DiskType: "hdd"},
{VolumeID: 2, Server: "server-a", DiskType: "hdd"},
{VolumeID: 3, Server: "server-b", DiskType: "hdd"},
{VolumeID: 4, Server: "server-b", DiskType: "hdd"},
}
if err := emitVolumeBalanceDetectionDecisionTrace(sender, metrics, nil, config, nil); err != nil {
t.Fatalf("emitVolumeBalanceDetectionDecisionTrace error: %v", err)
}
if len(sender.events) < 2 {
t.Fatalf("expected at least 2 detection events, got %d", len(sender.events))
}
if sender.events[0].Source != plugin_pb.ActivitySource_ACTIVITY_SOURCE_DETECTOR {
t.Fatalf("expected detector source, got %v", sender.events[0].Source)
}
if !strings.Contains(sender.events[0].Message, "BALANCE: No tasks created for 4 volumes") {
t.Fatalf("unexpected summary message: %q", sender.events[0].Message)
}
foundDiskTypeDecision := false
for _, event := range sender.events {
if strings.Contains(event.Message, "BALANCE [hdd]: No tasks created - cluster well balanced") {
foundDiskTypeDecision = true
break
}
}
if !foundDiskTypeDecision {
t.Fatalf("expected per-disk-type decision message")
}
}
func TestVolumeBalanceDescriptorOmitsExecutionTuningFields(t *testing.T) {
descriptor := NewVolumeBalanceHandler(nil).Descriptor()
if descriptor == nil || descriptor.WorkerConfigForm == nil {
t.Fatalf("expected worker config form in descriptor")
}
if workerConfigFormHasField(descriptor.WorkerConfigForm, "timeout_seconds") {
t.Fatalf("unexpected timeout_seconds in volume balance worker config form")
}
if workerConfigFormHasField(descriptor.WorkerConfigForm, "force_move") {
t.Fatalf("unexpected force_move in volume balance worker config form")
}
}
type recordingExecutionSender struct {
mu sync.Mutex
progress []*plugin_pb.JobProgressUpdate
completed *plugin_pb.JobCompleted
}
func (r *recordingExecutionSender) SendProgress(p *plugin_pb.JobProgressUpdate) error {
r.mu.Lock()
defer r.mu.Unlock()
r.progress = append(r.progress, proto.Clone(p).(*plugin_pb.JobProgressUpdate))
return nil
}
func (r *recordingExecutionSender) SendCompleted(c *plugin_pb.JobCompleted) error {
r.mu.Lock()
defer r.mu.Unlock()
r.completed = proto.Clone(c).(*plugin_pb.JobCompleted)
return nil
}
func TestBuildMoveTaskParams(t *testing.T) {
move := &worker_pb.BalanceMoveSpec{
VolumeId: 42,
SourceNode: "10.0.0.1:8080",
TargetNode: "10.0.0.2:8080",
Collection: "photos",
VolumeSize: 1024 * 1024,
}
outerParams := &worker_pb.BalanceTaskParams{
ForceMove: true,
TimeoutSeconds: 300,
}
params := buildMoveTaskParams(move, outerParams)
if params.VolumeId != 42 {
t.Fatalf("expected volume_id 42, got %d", params.VolumeId)
}
if params.Collection != "photos" {
t.Fatalf("expected collection photos, got %s", params.Collection)
}
if params.VolumeSize != 1024*1024 {
t.Fatalf("expected volume_size %d, got %d", 1024*1024, params.VolumeSize)
}
if len(params.Sources) != 1 || params.Sources[0].Node != "10.0.0.1:8080" {
t.Fatalf("unexpected sources: %+v", params.Sources)
}
if len(params.Targets) != 1 || params.Targets[0].Node != "10.0.0.2:8080" {
t.Fatalf("unexpected targets: %+v", params.Targets)
}
bp := params.GetBalanceParams()
if bp == nil {
t.Fatalf("expected balance params")
}
if bp.TimeoutSeconds != 300 {
t.Fatalf("expected timeout 300, got %d", bp.TimeoutSeconds)
}
if !bp.ForceMove {
t.Fatalf("expected force_move to be propagated from outer params")
}
}
func TestBuildMoveTaskParamsDefaultTimeout(t *testing.T) {
move := &worker_pb.BalanceMoveSpec{
VolumeId: 1,
SourceNode: "a:8080",
TargetNode: "b:8080",
}
params := buildMoveTaskParams(move, nil)
if params.GetBalanceParams().TimeoutSeconds != defaultBalanceTimeoutSeconds {
t.Fatalf("expected default timeout %d, got %d", defaultBalanceTimeoutSeconds, params.GetBalanceParams().TimeoutSeconds)
}
if params.GetBalanceParams().ForceMove {
t.Fatalf("expected force_move to default to false with nil outer params")
}
}
func TestExecuteDispatchesBatchPath(t *testing.T) {
// Build a job with batch moves in BalanceTaskParams
bp := &worker_pb.BalanceTaskParams{
TimeoutSeconds: 60,
MaxConcurrentMoves: 2,
Moves: []*worker_pb.BalanceMoveSpec{
{VolumeId: 1, SourceNode: "s1:8080", TargetNode: "t1:8080", Collection: "c1"},
{VolumeId: 2, SourceNode: "s2:8080", TargetNode: "t2:8080", Collection: "c1"},
},
}
taskParams := &worker_pb.TaskParams{
TaskId: "batch-1",
TaskParams: &worker_pb.TaskParams_BalanceParams{
BalanceParams: bp,
},
}
payload, err := proto.Marshal(taskParams)
if err != nil {
t.Fatalf("marshal: %v", err)
}
job := &plugin_pb.JobSpec{
JobId: "batch-job-1",
JobType: "volume_balance",
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
},
}
handler := NewVolumeBalanceHandler(nil)
sender := &recordingExecutionSender{}
// Execute will enter the batch path. It will fail because there's no real gRPC server,
// but we verify it sends the assigned progress and eventually a completion.
err = handler.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
Job: job,
}, sender)
if err != nil {
t.Fatalf("handler.Execute: %v", err)
}
// Expect an error since no real volume servers exist
// But verify the batch path was taken by checking the assigned message
sender.mu.Lock()
defer sender.mu.Unlock()
if len(sender.progress) == 0 {
t.Fatalf("expected progress messages from batch path")
}
// First progress should be "assigned" with batch info
first := sender.progress[0]
if first.Stage != "assigned" {
t.Fatalf("expected first stage 'assigned', got %q", first.Stage)
}
if !strings.Contains(first.Message, "batch") || !strings.Contains(first.Message, "2 moves") {
t.Fatalf("expected batch assigned message, got %q", first.Message)
}
// Should have a completion with failure details (since no servers)
if sender.completed == nil {
t.Fatalf("expected completion message")
}
if sender.completed.Success {
t.Fatalf("expected failure since no real gRPC servers")
}
// Should report 0 succeeded, 2 failed
if v, ok := sender.completed.Result.OutputValues["failed"]; !ok || v.GetInt64Value() != 2 {
t.Fatalf("expected 2 failed moves, got %+v", sender.completed.Result.OutputValues)
}
}
func TestExecuteSingleMovePathUnchanged(t *testing.T) {
// Build a single-move job (no batch moves)
taskParams := &worker_pb.TaskParams{
TaskId: "single-1",
VolumeId: 99,
Collection: "videos",
Sources: []*worker_pb.TaskSource{
{Node: "src:8080", VolumeId: 99},
},
Targets: []*worker_pb.TaskTarget{
{Node: "dst:8080", VolumeId: 99},
},
TaskParams: &worker_pb.TaskParams_BalanceParams{
BalanceParams: &worker_pb.BalanceTaskParams{
TimeoutSeconds: 60,
},
},
}
payload, err := proto.Marshal(taskParams)
if err != nil {
t.Fatalf("marshal: %v", err)
}
job := &plugin_pb.JobSpec{
JobId: "single-job-1",
JobType: "volume_balance",
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
},
}
handler := NewVolumeBalanceHandler(nil)
sender := &recordingExecutionSender{}
// Execute single-move path. Will fail on gRPC but verify it takes the single-move path.
_ = handler.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
Job: job,
}, sender)
sender.mu.Lock()
defer sender.mu.Unlock()
if len(sender.progress) == 0 {
t.Fatalf("expected progress messages")
}
// Single-move path sends "volume balance job accepted" not "batch volume balance"
first := sender.progress[0]
if first.Stage != "assigned" {
t.Fatalf("expected first stage 'assigned', got %q", first.Stage)
}
if strings.Contains(first.Message, "batch") {
t.Fatalf("single-move path should not mention batch, got %q", first.Message)
}
}
func TestFilterMetricsByLocation(t *testing.T) {
metrics := []*workertypes.VolumeHealthMetrics{
{VolumeID: 1, Server: "node-a", DataCenter: "dc1", Rack: "rack1"},
{VolumeID: 2, Server: "node-b", DataCenter: "dc1", Rack: "rack2"},
{VolumeID: 3, Server: "node-c", DataCenter: "dc2", Rack: "rack1"},
{VolumeID: 4, Server: "node-d", DataCenter: "dc2", Rack: "rack3"},
}
// Filter by DC
filtered := pluginworker.FilterMetricsByLocation(metrics, "dc1", "", "")
if len(filtered) != 2 {
t.Fatalf("DC filter: expected 2, got %d", len(filtered))
}
// Filter by rack
filtered = pluginworker.FilterMetricsByLocation(metrics, "", "rack1,rack2", "")
if len(filtered) != 3 {
t.Fatalf("rack filter: expected 3, got %d", len(filtered))
}
// Filter by node
filtered = pluginworker.FilterMetricsByLocation(metrics, "", "", "node-a,node-c")
if len(filtered) != 2 {
t.Fatalf("node filter: expected 2, got %d", len(filtered))
}
// Combined DC + rack
filtered = pluginworker.FilterMetricsByLocation(metrics, "dc2", "rack3", "")
if len(filtered) != 1 {
t.Fatalf("DC+rack filter: expected 1, got %d", len(filtered))
}
// Empty filters pass all
filtered = pluginworker.FilterMetricsByLocation(metrics, "", "", "")
if len(filtered) != 4 {
t.Fatalf("no filter: expected 4, got %d", len(filtered))
}
}
func TestFilterMetricsByVolumeState(t *testing.T) {
metrics := []*workertypes.VolumeHealthMetrics{
{VolumeID: 1, FullnessRatio: 0.5}, // active
{VolumeID: 2, FullnessRatio: 1.0}, // active (below 1.01)
{VolumeID: 3, FullnessRatio: 1.009}, // active (below 1.01)
{VolumeID: 4, FullnessRatio: 1.01}, // full (exactly at threshold)
{VolumeID: 5, FullnessRatio: 1.5}, // full
{VolumeID: 6, FullnessRatio: 2.0}, // full
}
tests := []struct {
name string
state string
expectedIDs []uint32
}{
{
name: "ALL returns everything",
state: "ALL",
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
},
{
name: "empty string returns everything",
state: "",
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
},
{
name: "ACTIVE keeps FullnessRatio below 1.01",
state: "ACTIVE",
expectedIDs: []uint32{1, 2, 3},
},
{
name: "FULL keeps FullnessRatio at or above 1.01",
state: "FULL",
expectedIDs: []uint32{4, 5, 6},
},
{
name: "unknown value returns everything",
state: "INVALID",
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := pluginworker.FilterMetricsByVolumeState(metrics, pluginworker.VolumeState(tt.state))
if len(result) != len(tt.expectedIDs) {
t.Fatalf("expected %d metrics, got %d", len(tt.expectedIDs), len(result))
}
for i, m := range result {
if m.VolumeID != tt.expectedIDs[i] {
t.Errorf("result[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.expectedIDs[i])
}
}
})
}
}
func TestFilterMetricsByVolumeState_NilElement(t *testing.T) {
metrics := []*workertypes.VolumeHealthMetrics{
nil,
{VolumeID: 1, FullnessRatio: 0.5},
nil,
{VolumeID: 2, FullnessRatio: 1.5},
}
result := pluginworker.FilterMetricsByVolumeState(metrics, pluginworker.VolumeStateActive)
if len(result) != 1 || result[0].VolumeID != 1 {
t.Fatalf("expected [vol 1] for ACTIVE with nil elements, got %d results", len(result))
}
result = pluginworker.FilterMetricsByVolumeState(metrics, pluginworker.VolumeStateFull)
if len(result) != 1 || result[0].VolumeID != 2 {
t.Fatalf("expected [vol 2] for FULL with nil elements, got %d results", len(result))
}
}
func TestFilterMetricsByVolumeState_EmptyInput(t *testing.T) {
result := pluginworker.FilterMetricsByVolumeState(nil, pluginworker.VolumeStateActive)
if len(result) != 0 {
t.Fatalf("expected 0 metrics for nil input, got %d", len(result))
}
result = pluginworker.FilterMetricsByVolumeState([]*workertypes.VolumeHealthMetrics{}, pluginworker.VolumeStateFull)
if len(result) != 0 {
t.Fatalf("expected 0 metrics for empty input, got %d", len(result))
}
}
func TestVolumeBalanceDescriptorHasVolumeStateField(t *testing.T) {
descriptor := NewVolumeBalanceHandler(nil).Descriptor()
if descriptor == nil || descriptor.AdminConfigForm == nil {
t.Fatalf("expected admin config form in descriptor")
}
found := false
for _, section := range descriptor.AdminConfigForm.Sections {
for _, field := range section.Fields {
if field.Name == "volume_state" {
found = true
break
}
}
}
if !found {
t.Fatalf("expected volume_state field in admin config form")
}
defaultVal, ok := descriptor.AdminConfigForm.DefaultValues["volume_state"]
if !ok {
t.Fatalf("expected volume_state default value")
}
if defaultVal.GetStringValue() != "ALL" {
t.Fatalf("expected volume_state default 'ALL', got %q", defaultVal.GetStringValue())
}
}
func workerConfigFormHasField(form *plugin_pb.ConfigForm, fieldName string) bool {
if form == nil {
return false
}
for _, section := range form.Sections {
if section == nil {
continue
}
for _, field := range section.Fields {
if field != nil && field.Name == fieldName {
return true
}
}
}
return false
}
type noopDetectionSender struct{}
func (noopDetectionSender) SendProposals(*plugin_pb.DetectionProposals) error { return nil }
func (noopDetectionSender) SendComplete(*plugin_pb.DetectionComplete) error { return nil }
func (noopDetectionSender) SendActivity(*plugin_pb.ActivityEvent) error { return nil }
type noopExecutionSender struct{}
func (noopExecutionSender) SendProgress(*plugin_pb.JobProgressUpdate) error { return nil }
func (noopExecutionSender) SendCompleted(*plugin_pb.JobCompleted) error { return nil }
type recordingDetectionSender struct {
proposals *plugin_pb.DetectionProposals
complete *plugin_pb.DetectionComplete
events []*plugin_pb.ActivityEvent
}
func (r *recordingDetectionSender) SendProposals(proposals *plugin_pb.DetectionProposals) error {
r.proposals = proposals
return nil
}
func (r *recordingDetectionSender) SendComplete(complete *plugin_pb.DetectionComplete) error {
r.complete = complete
return nil
}
func (r *recordingDetectionSender) SendActivity(event *plugin_pb.ActivityEvent) error {
if event != nil {
r.events = append(r.events, event)
}
return nil
}
func TestCheckMovePreconditions(t *testing.T) {
tests := []struct {
name string
locations []string
sourceNode string
targetNode string
wantErr string
}{
{"valid move", []string{"10.0.0.1:8080", "10.0.0.3:8080"}, "10.0.0.1:8080", "10.0.0.2:8080", ""},
{"volume left the source", []string{"10.0.0.3:8080"}, "10.0.0.1:8080", "10.0.0.2:8080", "no longer on source"},
{"volume gone entirely", nil, "10.0.0.1:8080", "10.0.0.2:8080", "no longer on source"},
{"target already has a replica", []string{"10.0.0.1:8080", "10.0.0.2:8080"}, "10.0.0.1:8080", "10.0.0.2:8080", "already has a replica on target"},
{"proposal nodes carry grpc suffix", []string{"10.0.0.1:8080", "10.0.0.3:8080"}, "10.0.0.1:8080.18080", "10.0.0.2:8080.18080", ""},
{"grpc-suffixed target already has a replica", []string{"10.0.0.1:8080", "10.0.0.2:8080"}, "10.0.0.1:8080.18080", "10.0.0.2:8080.18080", "already has a replica on target"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkMovePreconditions(tt.locations, 5, tt.sourceNode, tt.targetNode)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}

View File

@@ -0,0 +1,95 @@
package balance
import (
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Global variable to hold the task definition for configuration updates
var globalTaskDef *base.TaskDefinition
// Auto-register this task when the package is imported
func init() {
RegisterBalanceTask()
// Register config updater
tasks.AutoRegisterConfigUpdater(types.TaskTypeBalance, UpdateConfigFromPersistence)
}
// RegisterBalanceTask registers the balance task with the new architecture
func RegisterBalanceTask() {
// Create configuration instance
config := NewDefaultConfig()
// Create shared gRPC dial option using TLS configuration
dialOpt := security.LoadClientTLS(util.GetViper(), "grpc.worker")
// Create complete task definition
taskDef := &base.TaskDefinition{
Type: types.TaskTypeBalance,
Name: "balance",
DisplayName: "Volume Balance",
Description: "Balances volume distribution across servers",
Icon: "fas fa-balance-scale text-warning",
Capabilities: []string{"balance", "distribution"},
Config: config,
ConfigSpec: GetConfigSpec(),
CreateTask: func(params *worker_pb.TaskParams) (types.Task, error) {
if params == nil {
return nil, fmt.Errorf("task parameters are required")
}
if len(params.Sources) == 0 {
return nil, fmt.Errorf("at least one source is required for balance task")
}
return NewBalanceTask(
fmt.Sprintf("balance-%d", params.VolumeId),
params.Sources[0].Node, // Use first source node
params.VolumeId,
params.Collection,
dialOpt,
), nil
},
DetectionFunc: func(metrics []*types.VolumeHealthMetrics, info *types.ClusterInfo, config base.TaskConfig) ([]*types.TaskDetectionResult, error) {
results, _, err := Detection(metrics, info, config, 0)
return results, err
},
ScanInterval: 30 * time.Minute,
SchedulingFunc: Scheduling,
MaxConcurrent: 1,
RepeatInterval: 2 * time.Hour,
}
// Store task definition globally for configuration updates
globalTaskDef = taskDef
// Register everything with a single function call!
base.RegisterTask(taskDef)
}
// UpdateConfigFromPersistence updates the balance configuration from persistence
func UpdateConfigFromPersistence(configPersistence interface{}) error {
if globalTaskDef == nil {
return fmt.Errorf("balance task not registered")
}
// Load configuration from persistence
newConfig := LoadConfigFromPersistence(configPersistence)
if newConfig == nil {
return fmt.Errorf("failed to load configuration from persistence")
}
// Update the task definition's config
globalTaskDef.Config = newConfig
glog.V(1).Infof("Updated balance task configuration from persistence")
return nil
}

View File

@@ -0,0 +1,37 @@
package balance
import (
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/topology/balancer"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// hostFromAddress returns the physical machine (host/IP) of a server address,
// falling back to the node id when no address is known.
func hostFromAddress(address, nodeID string) string {
return balancer.HostFromAddress(address, nodeID)
}
// IsGoodMove checks whether moving a volume from sourceNodeID to target would
// satisfy the volume's replica placement policy. It is a thin adapter over the
// shared placement logic in weed/topology/balancer so the shell command and this
// worker cannot drift.
func IsGoodMove(rp *super_block.ReplicaPlacement, existingReplicas []types.ReplicaLocation, sourceNodeID string, target types.ReplicaLocation) bool {
locs := make([]balancer.Location, len(existingReplicas))
for i, r := range existingReplicas {
locs[i] = toBalancerLocation(r)
}
return balancer.IsGoodMove(rp, locs, sourceNodeID, toBalancerLocation(target))
}
// toBalancerLocation copies a worker replica location to the shared placement
// type field-by-field, so a future change to either struct is a compile error
// here rather than a silent mismatch.
func toBalancerLocation(r types.ReplicaLocation) balancer.Location {
return balancer.Location{
DataCenter: r.DataCenter,
Rack: r.Rack,
NodeID: r.NodeID,
Host: r.Host,
}
}

View File

@@ -0,0 +1,256 @@
package balance
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
func rp(t *testing.T, code string) *super_block.ReplicaPlacement {
t.Helper()
r, err := super_block.NewReplicaPlacementFromString(code)
if err != nil {
t.Fatalf("invalid replica placement code %q: %v", code, err)
}
return r
}
func loc(dc, rack, node string) types.ReplicaLocation {
return types.ReplicaLocation{DataCenter: dc, Rack: rack, NodeID: node}
}
func TestIsGoodMove_NoReplication(t *testing.T) {
// 000 = no replication. Any move is fine.
if !IsGoodMove(rp(t, "000"), []types.ReplicaLocation{loc("dc1", "r1", "n1")}, "n1", loc("dc1", "r1", "n2")) {
t.Error("000: any move should be allowed")
}
}
func TestIsGoodMove_MachineAntiAffinity(t *testing.T) {
// rep 001 allows two copies in one rack, so replica placement alone would permit
// moving onto another port of a host that already holds a replica; machine
// anti-affinity must reject that and allow a distinct host.
existing := []types.ReplicaLocation{
{DataCenter: "dc1", Rack: "r1", NodeID: "10.0.0.1:8080", Host: "10.0.0.1"},
{DataCenter: "dc1", Rack: "r1", NodeID: "10.0.0.2:8080", Host: "10.0.0.2"},
}
onSameMachine := types.ReplicaLocation{DataCenter: "dc1", Rack: "r1", NodeID: "10.0.0.1:8081", Host: "10.0.0.1"}
if IsGoodMove(rp(t, "001"), existing, "10.0.0.2:8080", onSameMachine) {
t.Error("move onto a machine already holding a replica should be rejected")
}
onOtherMachine := types.ReplicaLocation{DataCenter: "dc1", Rack: "r1", NodeID: "10.0.0.3:8080", Host: "10.0.0.3"}
if !IsGoodMove(rp(t, "001"), existing, "10.0.0.2:8080", onOtherMachine) {
t.Error("move onto a distinct machine should be allowed")
}
}
func TestIsGoodMove_001_SameRack(t *testing.T) {
// 001 = 1 replica on same rack (2 total on same rack)
existing := []types.ReplicaLocation{
loc("dc1", "r1", "n1"),
loc("dc1", "r1", "n2"),
}
// Move n1 -> n3 on same rack: good
if !IsGoodMove(rp(t, "001"), existing, "n1", loc("dc1", "r1", "n3")) {
t.Error("001: move to same rack should be allowed")
}
// Move n1 -> n3 on different rack: bad (would leave only 1 on r1, need 2)
if IsGoodMove(rp(t, "001"), existing, "n1", loc("dc1", "r2", "n3")) {
t.Error("001: move to different rack should not be allowed when it breaks same-rack count")
}
}
func TestIsGoodMove_010_DiffRack(t *testing.T) {
// 010 = 1 replica on different rack (2 racks total)
existing := []types.ReplicaLocation{
loc("dc1", "r1", "n1"),
loc("dc1", "r2", "n2"),
}
// Move n1 -> n3 on r2: bad (both replicas on same rack)
if IsGoodMove(rp(t, "010"), existing, "n1", loc("dc1", "r2", "n3")) {
t.Error("010: move to same rack as other replica should not be allowed")
}
// Move n1 -> n3 on r3: good (still 2 different racks)
if !IsGoodMove(rp(t, "010"), existing, "n1", loc("dc1", "r3", "n3")) {
t.Error("010: move to different rack should be allowed")
}
}
func TestIsGoodMove_100_DiffDC(t *testing.T) {
// 100 = 1 replica in different DC
existing := []types.ReplicaLocation{
loc("dc1", "r1", "n1"),
loc("dc2", "r1", "n2"),
}
// Move n1 -> n3 in dc2: bad (both in same DC)
if IsGoodMove(rp(t, "100"), existing, "n1", loc("dc2", "r1", "n3")) {
t.Error("100: move to same DC as other replica should not be allowed")
}
// Move n1 -> n3 in dc3: good (different DCs)
if !IsGoodMove(rp(t, "100"), existing, "n1", loc("dc3", "r1", "n3")) {
t.Error("100: move to different DC should be allowed")
}
}
func TestIsGoodMove_SameNode(t *testing.T) {
// Moving to the same node as an existing replica should always be rejected
existing := []types.ReplicaLocation{
loc("dc1", "r1", "n1"),
loc("dc1", "r2", "n2"),
}
if IsGoodMove(rp(t, "010"), existing, "n1", loc("dc1", "r2", "n2")) {
t.Error("should reject move to same node as existing replica")
}
}
func TestIsGoodMove_011_Composite(t *testing.T) {
// 011 = 1 same-rack + 1 different-rack (3 replicas: 2 on same rack, 1 on different)
existing := []types.ReplicaLocation{
loc("dc1", "r1", "n1"),
loc("dc1", "r1", "n2"),
loc("dc1", "r2", "n3"),
}
// Move n1 -> n4 on r1: good (maintains 2 on r1, 1 on r2)
if !IsGoodMove(rp(t, "011"), existing, "n1", loc("dc1", "r1", "n4")) {
t.Error("011: move within same rack should be allowed")
}
// Move n3 -> n4 on r1: bad (would have 3 on r1, 0 on different rack)
if IsGoodMove(rp(t, "011"), existing, "n3", loc("dc1", "r1", "n4")) {
t.Error("011: move that eliminates different-rack replica should not be allowed")
}
}
func TestIsGoodMove_110_Composite(t *testing.T) {
// 110 = 1 different-rack + 1 different-DC (3 replicas across 2 DCs and 2 racks)
existing := []types.ReplicaLocation{
loc("dc1", "r1", "n1"),
loc("dc1", "r2", "n2"),
loc("dc2", "r1", "n3"),
}
// Move n1 -> n4 in dc1/r3: good (dc1 still has r2+r3, dc2 has r1)
if !IsGoodMove(rp(t, "110"), existing, "n1", loc("dc1", "r3", "n4")) {
t.Error("110: move to new rack in same DC should be allowed")
}
// Move n3 -> n4 in dc1/r1: bad (would lose the different-DC replica)
if IsGoodMove(rp(t, "110"), existing, "n3", loc("dc1", "r1", "n4")) {
t.Error("110: move that eliminates different-DC replica should not be allowed")
}
}
func TestIsGoodMove_NilReplicaPlacement(t *testing.T) {
if !IsGoodMove(nil, []types.ReplicaLocation{loc("dc1", "r1", "n1")}, "n1", loc("dc1", "r1", "n2")) {
t.Error("nil replica placement should allow any move")
}
}
func TestCalculateBalanceScore_ReplicationAware(t *testing.T) {
disk := func(dc, rack string) *topology.DiskInfo {
return &topology.DiskInfo{
DataCenter: dc,
Rack: rack,
DiskInfo: &master_pb.DiskInfo{MaxVolumeCount: 100, VolumeCount: 50},
}
}
// 001: same-rack replication — should prefer same rack and same DC
rp001 := rp(t, "001")
sameRack := calculateBalanceScore(disk("dc1", "r1"), "r1", "dc1", 0, rp001)
diffRack := calculateBalanceScore(disk("dc1", "r2"), "r1", "dc1", 0, rp001)
diffDC := calculateBalanceScore(disk("dc2", "r2"), "r1", "dc1", 0, rp001)
if sameRack <= diffRack {
t.Errorf("001: same-rack score (%v) should exceed different-rack score (%v)", sameRack, diffRack)
}
if sameRack <= diffDC {
t.Errorf("001: same-rack score (%v) should exceed different-DC score (%v)", sameRack, diffDC)
}
// 010: different-rack replication — should prefer different rack, same DC
rp010 := rp(t, "010")
sameRack = calculateBalanceScore(disk("dc1", "r1"), "r1", "dc1", 0, rp010)
diffRack = calculateBalanceScore(disk("dc1", "r2"), "r1", "dc1", 0, rp010)
if diffRack <= sameRack {
t.Errorf("010: different-rack score (%v) should exceed same-rack score (%v)", diffRack, sameRack)
}
// 100: different-DC replication — should prefer different DC
rp100 := rp(t, "100")
sameDC := calculateBalanceScore(disk("dc1", "r2"), "r1", "dc1", 0, rp100)
diffDCScore := calculateBalanceScore(disk("dc2", "r2"), "r1", "dc1", 0, rp100)
if diffDCScore <= sameDC {
t.Errorf("100: different-DC score (%v) should exceed same-DC score (%v)", diffDCScore, sameDC)
}
// nil rp: should prefer cross-rack/DC (default behavior)
sameRack = calculateBalanceScore(disk("dc1", "r1"), "r1", "dc1", 0, nil)
diffRack = calculateBalanceScore(disk("dc1", "r2"), "r1", "dc1", 0, nil)
if diffRack <= sameRack {
t.Errorf("nil rp: different-rack score (%v) should exceed same-rack score (%v)", diffRack, sameRack)
}
}
func TestPlanBalanceDestination_ChoosesBestValidCompositeDestination(t *testing.T) {
servers := []serverSpec{
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack3"},
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1"},
}
volumes := makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 1, withReplicas(11))
replicas := []types.ReplicaLocation{
loc("dc1", "rack1", "node-a"),
loc("dc1", "rack1", "node-d"),
loc("dc1", "rack2", "node-e"),
}
plan, err := planBalanceDestination(buildTopology(servers, volumes), volumes[0], rp(t, "011"), replicas, map[string]int{
"node-b": 0,
"node-c": 0,
})
if err != nil {
t.Fatalf("planBalanceDestination failed: %v", err)
}
if plan.TargetNode != "node-c" {
t.Fatalf("expected valid same-rack destination node-c, got %s", plan.TargetNode)
}
if plan.TargetRack != "rack1" {
t.Fatalf("expected rack1 destination, got %s", plan.TargetRack)
}
}
func TestCreateBalanceTask_FallbackSelectsValidCompositeDestination(t *testing.T) {
servers := []serverSpec{
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack3"},
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1"},
}
volumes := makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 1, withReplicas(11))
clusterInfo := &types.ClusterInfo{
ActiveTopology: buildTopology(servers, volumes),
VolumeReplicaMap: map[uint32][]types.ReplicaLocation{
1: {
loc("dc1", "rack1", "node-a"),
loc("dc1", "rack1", "node-d"),
loc("dc1", "rack2", "node-e"),
},
},
}
task, destination := createBalanceTask("hdd", volumes[0], clusterInfo, "node-b", map[string]int{
"node-b": 0,
"node-c": 0,
}, 0)
if task == nil {
t.Fatal("expected a balance task")
}
if destination != "node-c" {
t.Fatalf("expected fallback destination node-c, got %s", destination)
}
if len(task.TypedParams.Targets) != 1 {
t.Fatalf("expected 1 target, got %d", len(task.TypedParams.Targets))
}
if got := task.TypedParams.Targets[0].Node; got != "node-c:8080" {
t.Fatalf("expected target node-c:8080, got %s", got)
}
}

View File

@@ -0,0 +1,37 @@
package balance
import (
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Scheduling implements the scheduling logic for balance tasks
func Scheduling(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData, config base.TaskConfig) bool {
balanceConfig := config.(*Config)
// Count running balance tasks
runningBalanceCount := 0
for _, runningTask := range runningTasks {
if runningTask.Type == types.TaskTypeBalance {
runningBalanceCount++
}
}
// Check concurrency limit
if runningBalanceCount >= balanceConfig.MaxConcurrent {
return false
}
// Check if we have available workers
availableWorkerCount := 0
for _, worker := range availableWorkers {
for _, capability := range worker.Capabilities {
if capability == types.TaskTypeBalance {
availableWorkerCount++
break
}
}
}
return availableWorkerCount > 0
}

View File

@@ -0,0 +1,160 @@
package base
import (
"time"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// GenericDetector implements TaskDetector using function-based logic
type GenericDetector struct {
taskDef *TaskDefinition
}
// NewGenericDetector creates a detector from a task definition
func NewGenericDetector(taskDef *TaskDefinition) *GenericDetector {
return &GenericDetector{taskDef: taskDef}
}
// GetTaskType returns the task type
func (d *GenericDetector) GetTaskType() types.TaskType {
return d.taskDef.Type
}
// ScanForTasks scans using the task definition's detection function
func (d *GenericDetector) ScanForTasks(volumeMetrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo) ([]*types.TaskDetectionResult, error) {
if d.taskDef.DetectionFunc == nil {
return nil, nil
}
return d.taskDef.DetectionFunc(volumeMetrics, clusterInfo, d.taskDef.Config)
}
// ScanInterval returns the scan interval from task definition
func (d *GenericDetector) ScanInterval() time.Duration {
if d.taskDef.ScanInterval > 0 {
return d.taskDef.ScanInterval
}
return 30 * time.Minute // Default
}
// IsEnabled returns whether this detector is enabled
func (d *GenericDetector) IsEnabled() bool {
return d.taskDef.Config.IsEnabled()
}
// SetEnabled turns detection for this task type on or off.
//
// The admin maintenance policy is applied to detectors through an
// interface{ SetEnabled(bool) } type assertion (see
// MaintenanceIntegration.configureDetectorFromPolicy). Every registered task is
// backed by this generic detector, so without this method that assertion failed
// for every task and the policy never reached the flag that
// ScanWithTaskDetectors actually gates on.
func (d *GenericDetector) SetEnabled(enabled bool) {
d.taskDef.Config.SetEnabled(enabled)
}
// GenericScheduler implements TaskScheduler using function-based logic
type GenericScheduler struct {
taskDef *TaskDefinition
}
// NewGenericScheduler creates a scheduler from a task definition
func NewGenericScheduler(taskDef *TaskDefinition) *GenericScheduler {
return &GenericScheduler{taskDef: taskDef}
}
// GetTaskType returns the task type
func (s *GenericScheduler) GetTaskType() types.TaskType {
return s.taskDef.Type
}
// CanScheduleNow determines if a task can be scheduled using the task definition's function
func (s *GenericScheduler) CanScheduleNow(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData) bool {
if s.taskDef.SchedulingFunc == nil {
return s.defaultCanSchedule(task, runningTasks, availableWorkers)
}
return s.taskDef.SchedulingFunc(task, runningTasks, availableWorkers, s.taskDef.Config)
}
// defaultCanSchedule provides default scheduling logic
func (s *GenericScheduler) defaultCanSchedule(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData) bool {
if !s.taskDef.Config.IsEnabled() {
return false
}
// Count running tasks of this type
runningCount := 0
for _, runningTask := range runningTasks {
if runningTask.Type == s.taskDef.Type {
runningCount++
}
}
// Check concurrency limit
maxConcurrent := s.taskDef.MaxConcurrent
if maxConcurrent <= 0 {
maxConcurrent = 1 // Default
}
if runningCount >= maxConcurrent {
return false
}
// Check if we have available workers
for _, worker := range availableWorkers {
if worker.CurrentLoad < worker.MaxConcurrent {
for _, capability := range worker.Capabilities {
if capability == s.taskDef.Type {
return true
}
}
}
}
return false
}
// GetPriority returns the priority for this task
func (s *GenericScheduler) GetPriority(task *types.TaskInput) types.TaskPriority {
return task.Priority
}
// GetMaxConcurrent returns max concurrent tasks
func (s *GenericScheduler) GetMaxConcurrent() int {
if s.taskDef.MaxConcurrent > 0 {
return s.taskDef.MaxConcurrent
}
return 1 // Default
}
// GetDefaultRepeatInterval returns the default repeat interval
func (s *GenericScheduler) GetDefaultRepeatInterval() time.Duration {
if s.taskDef.RepeatInterval > 0 {
return s.taskDef.RepeatInterval
}
return 24 * time.Hour // Default
}
// IsEnabled returns whether this scheduler is enabled
func (s *GenericScheduler) IsEnabled() bool {
return s.taskDef.Config.IsEnabled()
}
// SetEnabled turns scheduling for this task type on or off. Detector and scheduler
// share one TaskDefinition, so this is the same flag GenericDetector.SetEnabled sets;
// both setters exist because the maintenance integration configures the two
// independently. See GenericDetector.SetEnabled.
func (s *GenericScheduler) SetEnabled(enabled bool) {
s.taskDef.Config.SetEnabled(enabled)
}
// SetMaxConcurrent applies the policy's concurrency limit for this task type. It is
// the value GetMaxConcurrent returns, which the maintenance queue uses to decide
// whether another task of this type may start. Non-positive limits are ignored
// rather than turning into the implicit default of 1.
func (s *GenericScheduler) SetMaxConcurrent(maxConcurrent int) {
if maxConcurrent <= 0 {
return
}
s.taskDef.MaxConcurrent = maxConcurrent
}

View File

@@ -0,0 +1,151 @@
package base
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/config"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// GenericFactory creates task instances using a TaskDefinition
type GenericFactory struct {
*tasks.BaseTaskFactory
taskDef *TaskDefinition
}
// NewGenericFactory creates a generic task factory
func NewGenericFactory(taskDef *TaskDefinition) *GenericFactory {
return &GenericFactory{
BaseTaskFactory: tasks.NewBaseTaskFactory(
taskDef.Type,
taskDef.Capabilities,
taskDef.Description,
),
taskDef: taskDef,
}
}
// Create creates a task instance using the task definition
func (f *GenericFactory) Create(params *worker_pb.TaskParams) (types.Task, error) {
if f.taskDef.CreateTask == nil {
return nil, fmt.Errorf("no task creation function defined for %s", f.taskDef.Type)
}
return f.taskDef.CreateTask(params)
}
// Type returns the task type
func (f *GenericFactory) Type() string {
return string(f.taskDef.Type)
}
// Description returns a description of what this task does
func (f *GenericFactory) Description() string {
return f.taskDef.Description
}
// Capabilities returns the task capabilities
func (f *GenericFactory) Capabilities() []string {
return f.taskDef.Capabilities
}
// GenericSchemaProvider provides config schema from TaskDefinition
type GenericSchemaProvider struct {
taskDef *TaskDefinition
}
// GetConfigSchema returns the schema from task definition
func (p *GenericSchemaProvider) GetConfigSchema() *tasks.TaskConfigSchema {
return &tasks.TaskConfigSchema{
TaskName: string(p.taskDef.Type),
DisplayName: p.taskDef.DisplayName,
Description: p.taskDef.Description,
Icon: p.taskDef.Icon,
Schema: config.Schema{
Fields: p.taskDef.ConfigSpec.Fields,
},
}
}
// GenericUIProvider provides UI functionality from TaskDefinition
type GenericUIProvider struct {
taskDef *TaskDefinition
}
// GetCurrentConfig returns current config as TaskConfig
func (ui *GenericUIProvider) GetCurrentConfig() types.TaskConfig {
return ui.taskDef.Config
}
// ApplyTaskPolicy applies protobuf TaskPolicy configuration
func (ui *GenericUIProvider) ApplyTaskPolicy(policy *worker_pb.TaskPolicy) error {
return ui.taskDef.Config.FromTaskPolicy(policy)
}
// ApplyTaskConfig applies TaskConfig interface configuration
func (ui *GenericUIProvider) ApplyTaskConfig(config types.TaskConfig) error {
taskPolicy := config.ToTaskPolicy()
return ui.taskDef.Config.FromTaskPolicy(taskPolicy)
}
// RegisterTask registers a complete task definition with all registries
func RegisterTask(taskDef *TaskDefinition) {
// Validate task definition
if err := validateTaskDefinition(taskDef); err != nil {
glog.Errorf("Invalid task definition for %s: %v", taskDef.Type, err)
return
}
// Create and register factory
factory := NewGenericFactory(taskDef)
tasks.AutoRegister(taskDef.Type, factory)
// Create and register detector/scheduler
detector := NewGenericDetector(taskDef)
scheduler := NewGenericScheduler(taskDef)
tasks.AutoRegisterTypes(func(registry *types.TaskRegistry) {
registry.RegisterTask(detector, scheduler)
})
// Create and register schema provider
schemaProvider := &GenericSchemaProvider{taskDef: taskDef}
tasks.RegisterTaskConfigSchema(string(taskDef.Type), schemaProvider)
// Create and register UI provider
uiProvider := &GenericUIProvider{taskDef: taskDef}
tasks.AutoRegisterUI(func(uiRegistry *types.UIRegistry) {
baseUIProvider := tasks.NewBaseUIProvider(
taskDef.Type,
taskDef.DisplayName,
taskDef.Description,
taskDef.Icon,
schemaProvider.GetConfigSchema,
uiProvider.GetCurrentConfig,
uiProvider.ApplyTaskPolicy,
uiProvider.ApplyTaskConfig,
)
uiRegistry.RegisterUI(baseUIProvider)
})
glog.V(1).Infof("Registered complete task definition: %s", taskDef.Type)
}
// validateTaskDefinition ensures the task definition is complete
func validateTaskDefinition(taskDef *TaskDefinition) error {
if taskDef.Type == "" {
return fmt.Errorf("task type is required")
}
if taskDef.Name == "" {
return fmt.Errorf("task name is required")
}
if taskDef.Config == nil {
return fmt.Errorf("task config is required")
}
if taskDef.CreateTask == nil {
return fmt.Errorf("task creation function is required")
}
return nil
}

View File

@@ -0,0 +1,105 @@
package base
import (
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/config"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// TaskDefinition encapsulates everything needed to define a complete task type
type TaskDefinition struct {
// Basic task information
Type types.TaskType
Name string
DisplayName string
Description string
Icon string
Capabilities []string
// Task configuration
Config TaskConfig
ConfigSpec ConfigSpec
// Task creation
CreateTask func(params *worker_pb.TaskParams) (types.Task, error)
// Detection logic
DetectionFunc func(metrics []*types.VolumeHealthMetrics, info *types.ClusterInfo, config TaskConfig) ([]*types.TaskDetectionResult, error)
ScanInterval time.Duration
// Scheduling logic
SchedulingFunc func(task *types.TaskInput, running []*types.TaskInput, workers []*types.WorkerData, config TaskConfig) bool
MaxConcurrent int
RepeatInterval time.Duration
}
// TaskConfig provides a configuration interface that supports type-safe defaults
type TaskConfig interface {
config.ConfigWithDefaults // Extends ConfigWithDefaults for type-safe schema operations
IsEnabled() bool
SetEnabled(bool)
ToTaskPolicy() *worker_pb.TaskPolicy
FromTaskPolicy(policy *worker_pb.TaskPolicy) error
}
// ConfigSpec defines the configuration schema
type ConfigSpec struct {
Fields []*config.Field
}
// BaseConfig provides common configuration fields with reflection-based serialization
type BaseConfig struct {
Enabled bool `json:"enabled"`
ScanIntervalSeconds int `json:"scan_interval_seconds"`
MaxConcurrent int `json:"max_concurrent"`
}
// IsEnabled returns whether the task is enabled
func (c *BaseConfig) IsEnabled() bool {
return c.Enabled
}
// SetEnabled sets whether the task is enabled
func (c *BaseConfig) SetEnabled(enabled bool) {
c.Enabled = enabled
}
// Validate validates the base configuration
func (c *BaseConfig) Validate() error {
// Common validation logic
return nil
}
// ToMap converts config to map using reflection
// ToTaskPolicy converts BaseConfig to protobuf (partial implementation)
// Note: Concrete implementations should override this to include task-specific config
func (c *BaseConfig) ToTaskPolicy() *worker_pb.TaskPolicy {
return &worker_pb.TaskPolicy{
Enabled: c.Enabled,
MaxConcurrent: int32(c.MaxConcurrent),
RepeatIntervalSeconds: int32(c.ScanIntervalSeconds),
CheckIntervalSeconds: int32(c.ScanIntervalSeconds),
// TaskConfig field should be set by concrete implementations
}
}
// FromTaskPolicy loads BaseConfig from protobuf (partial implementation)
// Note: Concrete implementations should override this to handle task-specific config
func (c *BaseConfig) FromTaskPolicy(policy *worker_pb.TaskPolicy) error {
if policy == nil {
return fmt.Errorf("policy is nil")
}
c.Enabled = policy.Enabled
c.MaxConcurrent = int(policy.MaxConcurrent)
c.ScanIntervalSeconds = int(policy.RepeatIntervalSeconds)
return nil
}
// ApplySchemaDefaults applies default values from schema using reflection
func (c *BaseConfig) ApplySchemaDefaults(schema *config.Schema) error {
// Use reflection-based approach for BaseConfig since it needs to handle embedded structs
return schema.ApplyDefaultsToProtobuf(c)
}

View File

@@ -0,0 +1,247 @@
package base
import (
"errors"
"fmt"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// BaseTypedTask provides a base implementation for typed tasks with logger support
type BaseTypedTask struct {
taskType types.TaskType
taskID string
progress float64
progressCallback func(float64, string)
currentStage string
cancelled bool
mutex sync.RWMutex
// Logger functionality
logger tasks.TaskLogger
loggerConfig types.TaskLoggerConfig
}
// NewBaseTypedTask creates a new base typed task
func NewBaseTypedTask(taskType types.TaskType) *BaseTypedTask {
return &BaseTypedTask{
taskType: taskType,
progress: 0.0,
loggerConfig: types.TaskLoggerConfig{
BaseLogDir: "/data/task_logs",
MaxTasks: 100,
MaxLogSizeMB: 10,
EnableConsole: true,
},
}
}
// GetType returns the task type
func (bt *BaseTypedTask) GetType() types.TaskType {
return bt.taskType
}
// IsCancellable returns whether the task can be cancelled
func (bt *BaseTypedTask) IsCancellable() bool {
return true // Most tasks can be cancelled
}
// Cancel cancels the task
func (bt *BaseTypedTask) Cancel() error {
bt.mutex.Lock()
defer bt.mutex.Unlock()
bt.cancelled = true
return nil
}
// IsCancelled returns whether the task has been cancelled
func (bt *BaseTypedTask) IsCancelled() bool {
bt.mutex.RLock()
defer bt.mutex.RUnlock()
return bt.cancelled
}
// GetProgress returns the current progress (0-100)
func (bt *BaseTypedTask) GetProgress() float64 {
bt.mutex.RLock()
defer bt.mutex.RUnlock()
return bt.progress
}
// SetProgress sets the current progress and calls the callback if set
func (bt *BaseTypedTask) SetProgress(progress float64) {
bt.mutex.Lock()
callback := bt.progressCallback
stage := bt.currentStage
bt.progress = progress
bt.mutex.Unlock()
if callback != nil {
callback(progress, stage)
}
}
// SetProgressCallback sets the progress callback function
func (bt *BaseTypedTask) SetProgressCallback(callback func(float64, string)) {
bt.mutex.Lock()
defer bt.mutex.Unlock()
bt.progressCallback = callback
}
// SetProgressWithStage sets the current progress with a stage description
func (bt *BaseTypedTask) SetProgressWithStage(progress float64, stage string) {
bt.mutex.Lock()
callback := bt.progressCallback
bt.progress = progress
bt.currentStage = stage
bt.mutex.Unlock()
if callback != nil {
callback(progress, stage)
}
}
// SetCurrentStage sets the current stage description
func (bt *BaseTypedTask) SetCurrentStage(stage string) {
bt.mutex.Lock()
defer bt.mutex.Unlock()
bt.currentStage = stage
}
// GetCurrentStage returns the current stage description
func (bt *BaseTypedTask) GetCurrentStage() string {
bt.mutex.RLock()
defer bt.mutex.RUnlock()
return bt.currentStage
}
// SetLoggerConfig sets the logger configuration for this task
func (bt *BaseTypedTask) SetLoggerConfig(config types.TaskLoggerConfig) {
bt.mutex.Lock()
defer bt.mutex.Unlock()
bt.loggerConfig = config
}
// convertToTasksLoggerConfig converts types.TaskLoggerConfig to tasks.TaskLoggerConfig
func convertToTasksLoggerConfig(config types.TaskLoggerConfig) tasks.TaskLoggerConfig {
return tasks.TaskLoggerConfig{
BaseLogDir: config.BaseLogDir,
MaxTasks: config.MaxTasks,
MaxLogSizeMB: config.MaxLogSizeMB,
EnableConsole: config.EnableConsole,
}
}
// InitializeTaskLogger initializes the task logger with task details (LoggerProvider interface)
func (bt *BaseTypedTask) InitializeTaskLogger(taskID string, workerID string, params types.TaskParams) error {
bt.mutex.Lock()
defer bt.mutex.Unlock()
bt.taskID = taskID
// Convert the logger config to the tasks package type
tasksLoggerConfig := convertToTasksLoggerConfig(bt.loggerConfig)
logger, err := tasks.NewTaskLogger(taskID, bt.taskType, workerID, params, tasksLoggerConfig)
if err != nil {
return fmt.Errorf("failed to initialize task logger: %w", err)
}
bt.logger = logger
if bt.logger != nil {
bt.logger.Info("BaseTypedTask initialized for task %s (type: %s)", taskID, bt.taskType)
}
return nil
}
// GetTaskLogger returns the task logger (LoggerProvider interface)
func (bt *BaseTypedTask) GetTaskLogger() types.TaskLogger {
bt.mutex.RLock()
defer bt.mutex.RUnlock()
return bt.logger
}
// LogInfo logs an info message
func (bt *BaseTypedTask) LogInfo(message string, args ...interface{}) {
bt.mutex.RLock()
logger := bt.logger
bt.mutex.RUnlock()
if logger != nil {
logger.Info(message, args...)
}
}
// LogWarning logs a warning message
func (bt *BaseTypedTask) LogWarning(message string, args ...interface{}) {
bt.mutex.RLock()
logger := bt.logger
bt.mutex.RUnlock()
if logger != nil {
logger.Warning(message, args...)
}
}
// LogError logs an error message
func (bt *BaseTypedTask) LogError(message string, args ...interface{}) {
bt.mutex.RLock()
logger := bt.logger
bt.mutex.RUnlock()
if logger != nil {
logger.Error(message, args...)
}
}
// LogDebug logs a debug message
func (bt *BaseTypedTask) LogDebug(message string, args ...interface{}) {
bt.mutex.RLock()
logger := bt.logger
bt.mutex.RUnlock()
if logger != nil {
logger.Debug(message, args...)
}
}
// LogWithFields logs a message with structured fields
func (bt *BaseTypedTask) LogWithFields(level string, message string, fields map[string]interface{}) {
bt.mutex.RLock()
logger := bt.logger
bt.mutex.RUnlock()
if logger != nil {
logger.LogWithFields(level, message, fields)
}
}
// ValidateTyped provides basic validation for typed parameters
func (bt *BaseTypedTask) ValidateTyped(params *worker_pb.TaskParams) error {
if params == nil {
return errors.New("task parameters cannot be nil")
}
if params.VolumeId == 0 {
return errors.New("volume_id is required")
}
if len(params.Sources) == 0 {
return errors.New("at least one source is required")
}
return nil
}
// EstimateTimeTyped provides a default time estimation
func (bt *BaseTypedTask) EstimateTimeTyped(params *worker_pb.TaskParams) time.Duration {
// Default estimation - concrete tasks should override this
return 5 * time.Minute
}
// ExecuteTyped is a placeholder that concrete tasks must implement
func (bt *BaseTypedTask) ExecuteTyped(params *worker_pb.TaskParams) error {
panic("ExecuteTyped must be implemented by concrete task types")
}

View File

@@ -0,0 +1,36 @@
package base
import (
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
)
// FindVolumeDisk finds the disk ID where a specific volume is located on a given server.
// Returns the disk ID and a boolean indicating whether the volume was found.
// Uses O(1) indexed lookup for optimal performance on large clusters.
//
// This is a shared utility function used by multiple task detection algorithms
// (balance, vacuum, etc.) to locate volumes efficiently.
//
// Example usage:
//
// // In balance task: find source disk for a volume that needs to be moved
// sourceDisk, found := base.FindVolumeDisk(topology, volumeID, collection, sourceServer)
//
// // In vacuum task: find disk containing volume that needs cleanup
// diskID, exists := base.FindVolumeDisk(topology, volumeID, collection, serverID)
func FindVolumeDisk(activeTopology *topology.ActiveTopology, volumeID uint32, collection string, serverID string) (uint32, bool) {
if activeTopology == nil {
return 0, false
}
// Use the new O(1) indexed lookup for better performance
locations := activeTopology.GetVolumeLocations(volumeID, collection)
for _, loc := range locations {
if loc.ServerID == serverID {
return loc.DiskID, true
}
}
// Volume not found on this server
return 0, false
}

View File

@@ -0,0 +1,67 @@
package tasks
import (
"sync"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// ConfigUpdateFunc is a function type for updating task configurations
type ConfigUpdateFunc func(configPersistence interface{}) error
// ConfigUpdateRegistry manages config update functions for all task types
type ConfigUpdateRegistry struct {
updaters map[types.TaskType]ConfigUpdateFunc
mutex sync.RWMutex
}
var (
globalConfigUpdateRegistry *ConfigUpdateRegistry
configUpdateRegistryOnce sync.Once
)
// GetGlobalConfigUpdateRegistry returns the global config update registry (singleton)
func GetGlobalConfigUpdateRegistry() *ConfigUpdateRegistry {
configUpdateRegistryOnce.Do(func() {
globalConfigUpdateRegistry = &ConfigUpdateRegistry{
updaters: make(map[types.TaskType]ConfigUpdateFunc),
}
glog.V(1).Infof("Created global config update registry")
})
return globalConfigUpdateRegistry
}
// RegisterConfigUpdater registers a config update function for a task type
func (r *ConfigUpdateRegistry) RegisterConfigUpdater(taskType types.TaskType, updateFunc ConfigUpdateFunc) {
r.mutex.Lock()
defer r.mutex.Unlock()
r.updaters[taskType] = updateFunc
glog.V(1).Infof("Registered config updater for task type: %s", taskType)
}
// UpdateAllConfigs updates configurations for all registered task types
func (r *ConfigUpdateRegistry) UpdateAllConfigs(configPersistence interface{}) {
r.mutex.RLock()
updaters := make(map[types.TaskType]ConfigUpdateFunc)
for k, v := range r.updaters {
updaters[k] = v
}
r.mutex.RUnlock()
for taskType, updateFunc := range updaters {
if err := updateFunc(configPersistence); err != nil {
glog.Warningf("Failed to load %s configuration from persistence: %v", taskType, err)
} else {
glog.V(1).Infof("Loaded %s configuration from persistence", taskType)
}
}
glog.V(1).Infof("All task configurations loaded from persistence")
}
// AutoRegisterConfigUpdater is a convenience function for registering config updaters
func AutoRegisterConfigUpdater(taskType types.TaskType, updateFunc ConfigUpdateFunc) {
registry := GetGlobalConfigUpdateRegistry()
registry.RegisterConfigUpdater(taskType, updateFunc)
}

View File

@@ -0,0 +1,267 @@
package ec_balance
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/config"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
)
// Config extends BaseConfig with EC balance specific settings
type Config struct {
base.BaseConfig
ImbalanceThreshold float64 `json:"imbalance_threshold"`
MinServerCount int `json:"min_server_count"`
CollectionFilter string `json:"collection_filter"`
DiskType string `json:"disk_type"`
PreferredTags []string `json:"preferred_tags"`
ReplicaPlacement string `json:"replica_placement"` // e.g. "020"; empty falls back to the master default replication (even spread only when that default is empty or zero)
IoBytePerSecond int64 `json:"io_byte_per_second"` // limit each shard copy's rate; 0 falls back to the volume server's maintenance rate
DataCenterFilter string `json:"-"` // per-detection-run, not persisted
}
// NewDefaultConfig creates a new default EC balance configuration
func NewDefaultConfig() *Config {
return &Config{
BaseConfig: base.BaseConfig{
Enabled: true,
ScanIntervalSeconds: 60 * 60, // 1 hour
MaxConcurrent: 1,
},
ImbalanceThreshold: 0.2, // 20%
MinServerCount: 3,
CollectionFilter: "",
DiskType: "",
PreferredTags: nil,
}
}
// GetConfigSpec returns the configuration schema for EC balance tasks
func GetConfigSpec() base.ConfigSpec {
return base.ConfigSpec{
Fields: []*config.Field{
{
Name: "enabled",
JSONName: "enabled",
Type: config.FieldTypeBool,
DefaultValue: true,
Required: false,
DisplayName: "Enable EC Shard Balance Tasks",
Description: "Whether EC shard balance tasks should be automatically created",
HelpText: "Toggle this to enable or disable automatic EC shard balancing",
InputType: "checkbox",
CSSClasses: "form-check-input",
},
{
Name: "scan_interval_seconds",
JSONName: "scan_interval_seconds",
Type: config.FieldTypeInterval,
DefaultValue: 60 * 60,
MinValue: 10 * 60,
MaxValue: 24 * 60 * 60,
Required: true,
DisplayName: "Scan Interval",
Description: "How often to scan for EC shard imbalances",
HelpText: "The system will check for EC shard distribution imbalances at this interval",
Placeholder: "1",
Unit: config.UnitHours,
InputType: "interval",
CSSClasses: "form-control",
},
{
Name: "max_concurrent",
JSONName: "max_concurrent",
Type: config.FieldTypeInt,
DefaultValue: 1,
MinValue: 1,
MaxValue: 5,
Required: true,
DisplayName: "Max Concurrent Tasks",
Description: "Maximum number of EC shard balance tasks that can run simultaneously",
HelpText: "Limits the number of EC shard balancing operations running at the same time",
Placeholder: "1 (default)",
Unit: config.UnitCount,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "imbalance_threshold",
JSONName: "imbalance_threshold",
Type: config.FieldTypeFloat,
DefaultValue: 0.2,
MinValue: 0.05,
MaxValue: 0.5,
Required: true,
DisplayName: "Imbalance Threshold",
Description: "Minimum shard count imbalance ratio to trigger balancing",
HelpText: "EC shard distribution imbalances above this threshold will trigger rebalancing",
Placeholder: "0.20 (20%)",
Unit: config.UnitNone,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "min_server_count",
JSONName: "min_server_count",
Type: config.FieldTypeInt,
DefaultValue: 3,
MinValue: 2,
MaxValue: 100,
Required: true,
DisplayName: "Minimum Server Count",
Description: "Minimum number of servers required for EC shard balancing",
HelpText: "EC shard balancing will only occur if there are at least this many servers",
Placeholder: "3 (default)",
Unit: config.UnitCount,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "collection_filter",
JSONName: "collection_filter",
Type: config.FieldTypeString,
DefaultValue: "",
Required: false,
DisplayName: "Collection Filter",
Description: "Only balance EC shards from specific collections (comma-separated names, wildcards, or regex patterns)",
HelpText: "Leave empty to balance all collections, or list the collections to balance",
Placeholder: "pictures,videos",
InputType: "text",
CSSClasses: "form-control",
},
{
Name: "disk_type",
JSONName: "disk_type",
Type: config.FieldTypeString,
DefaultValue: "",
Required: false,
DisplayName: "Disk Type",
Description: "Only balance EC shards on this disk type",
HelpText: "Leave empty for all disk types, or specify hdd or ssd",
Placeholder: "hdd",
InputType: "text",
CSSClasses: "form-control",
},
{
Name: "preferred_tags",
JSONName: "preferred_tags",
Type: config.FieldTypeString,
DefaultValue: "",
Required: false,
DisplayName: "Preferred Disk Tags",
Description: "Comma-separated disk tags to prioritize for shard placement",
HelpText: "EC shards will be placed on disks with these tags first, ordered by preference",
Placeholder: "fast,ssd",
InputType: "text",
CSSClasses: "form-control",
},
{
Name: "replica_placement",
JSONName: "replica_placement",
Type: config.FieldTypeString,
DefaultValue: "",
Required: false,
DisplayName: "Replica Placement",
Description: "EC shard replica placement constraint (e.g. 020)",
HelpText: "Leave empty to use the master default replication (even spread only when that default is empty or zero). When set, limits shards per rack/node per the placement digits (dc/rack/node)",
Placeholder: "020",
InputType: "text",
CSSClasses: "form-control",
},
{
Name: "io_byte_per_second",
JSONName: "io_byte_per_second",
Type: config.FieldTypeInt,
DefaultValue: 0,
MinValue: 0,
Required: false,
DisplayName: "Shard Copy IO Limit (bytes/sec)",
Description: "Limit each EC shard copy's rate",
HelpText: "0 falls back to each volume server's own maintenance rate (-maintenanceBytePerSecond)",
Placeholder: "0 (server maintenance rate)",
Unit: config.UnitNone,
InputType: "number",
CSSClasses: "form-control",
},
},
}
}
// ToTaskPolicy converts configuration to a TaskPolicy protobuf message
func (c *Config) ToTaskPolicy() *worker_pb.TaskPolicy {
preferredTagsCopy := append([]string(nil), c.PreferredTags...)
return &worker_pb.TaskPolicy{
Enabled: c.Enabled,
MaxConcurrent: int32(c.MaxConcurrent),
RepeatIntervalSeconds: int32(c.ScanIntervalSeconds),
CheckIntervalSeconds: int32(c.ScanIntervalSeconds),
TaskConfig: &worker_pb.TaskPolicy_EcBalanceConfig{
EcBalanceConfig: &worker_pb.EcBalanceTaskConfig{
ImbalanceThreshold: c.ImbalanceThreshold,
MinServerCount: int32(c.MinServerCount),
CollectionFilter: c.CollectionFilter,
DiskType: c.DiskType,
PreferredTags: preferredTagsCopy,
ReplicaPlacement: c.ReplicaPlacement,
IoBytePerSecond: c.IoBytePerSecond,
},
},
}
}
// FromTaskPolicy loads configuration from a TaskPolicy protobuf message
func (c *Config) FromTaskPolicy(policy *worker_pb.TaskPolicy) error {
if policy == nil {
return fmt.Errorf("policy is nil")
}
c.Enabled = policy.Enabled
c.MaxConcurrent = int(policy.MaxConcurrent)
c.ScanIntervalSeconds = int(policy.RepeatIntervalSeconds)
if ecbConfig := policy.GetEcBalanceConfig(); ecbConfig != nil {
c.ImbalanceThreshold = ecbConfig.ImbalanceThreshold
c.MinServerCount = int(ecbConfig.MinServerCount)
c.CollectionFilter = ecbConfig.CollectionFilter
c.DiskType = ecbConfig.DiskType
c.PreferredTags = append([]string(nil), ecbConfig.PreferredTags...)
c.ReplicaPlacement = ecbConfig.ReplicaPlacement
c.IoBytePerSecond = ecbConfig.IoBytePerSecond
}
return nil
}
// LoadConfigFromPersistence loads configuration from the persistence layer if available
func LoadConfigFromPersistence(configPersistence interface{}) *Config {
cfg := NewDefaultConfig()
if persistence, ok := configPersistence.(interface {
LoadEcBalanceTaskPolicy() (*worker_pb.TaskPolicy, error)
}); ok {
policy, err := persistence.LoadEcBalanceTaskPolicy()
switch {
case err != nil:
glog.Warningf("Could not read the persisted EC balance configuration, falling back to defaults: %v", err)
case policy == nil:
glog.V(1).Infof("No EC balance configuration persisted yet, using defaults")
default:
if err := cfg.FromTaskPolicy(policy); err != nil {
glog.Warningf("Could not apply the persisted EC balance configuration, falling back to defaults: %v", err)
} else {
glog.V(1).Infof("Loaded EC balance configuration from persistence")
return cfg
}
}
} else if configPersistence != nil {
// A store was handed in but does not expose the accessor, so the persisted
// settings are silently ignored - always a wiring bug, never a normal state.
glog.Warningf("%T cannot provide the persisted EC balance configuration: it has no LoadEcBalanceTaskPolicy() method, "+
"so the compiled-in defaults are used and any saved EC balance settings are ignored", configPersistence)
}
glog.V(1).Infof("Using default EC balance configuration")
return cfg
}

View File

@@ -0,0 +1,128 @@
package ec_balance
import (
"context"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
)
// The dedup phase deletes a shard it believes is duplicated elsewhere. It picks
// its victims from the master's topology, and the topology can name a location
// that holds nothing: a volume server answering "not found ec volume id N" for
// a shard the master lists is the observable form of that. Deleting on the
// strength of a phantom peer removes the only real copy.
//
// The move phase already refuses to work on trust — it verifies the shard
// registered on the destination before removing the source. These tests hold
// dedup to the same standard, because it is the more dangerous of the two: it
// deletes without copying anything first.
const (
dedupTestVolumeID = uint32(42561)
dedupTestCollection = "pm-itatiaiucu-01"
)
func dedupParams(sourceNode, keepNode string, shardID uint32) *worker_pb.TaskParams {
// Dedup is signalled by source and target being the same node and disk.
loc := &worker_pb.TaskSource{Node: sourceNode, DiskId: 0, ShardIds: []uint32{shardID}}
return &worker_pb.TaskParams{
VolumeId: dedupTestVolumeID,
Collection: dedupTestCollection,
Sources: []*worker_pb.TaskSource{loc},
Targets: []*worker_pb.TaskTarget{{Node: sourceNode, DiskId: 0, ShardIds: []uint32{shardID}}},
// The dedup branch is only reachable when EC params are present; without
// them the job silently takes the copy-and-move path instead.
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
EcBalanceParams: &worker_pb.EcBalanceTaskParams{DedupKeepNode: keepNode},
},
}
}
func newDedupTask() *ECBalanceTask {
return NewECBalanceTask("dedup-test", dedupTestVolumeID, dedupTestCollection,
grpc.WithTransportCredentials(insecure.NewCredentials()))
}
// The failure that cost this cluster shards: the topology claims shard 0 lives
// on two nodes, only one of them actually has it, and dedup is pointed at the
// one that does. Nothing else in the cluster holds shard 0, so deleting it is
// unrecoverable — and no copy was made first, because dedup never makes one.
func TestDedupRefusesToDeleteTheOnlyRealCopy(t *testing.T) {
holder := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
// The peer the topology believes also has shard 0. Its disks are empty,
// exactly like a server answering "not found ec volume id".
phantom := startFakeEcVolumeServer(t, dedupTestVolumeID)
if !holder.has(dedupTestVolumeID, 0) {
t.Fatal("precondition: holder should start with shard 0")
}
if phantom.has(dedupTestVolumeID, 0) {
t.Fatal("precondition: phantom should hold nothing")
}
err := newDedupTask().Execute(context.Background(), dedupParams(holder.address(), phantom.address(), 0))
if holder.has(dedupTestVolumeID, 0) {
return // refused, or verified and backed off — either is safe
}
t.Fatalf("dedup deleted the only copy of shard %d.0 (deleted=%v, err=%v); "+
"the surviving copy was never confirmed to exist",
dedupTestVolumeID, holder.deletedShards(), err)
}
// A shard genuinely present on two servers is safe to thin out: one copy is
// redundant and removing it loses nothing. Dedup has to keep working here, or
// the guard above has simply disabled the feature.
func TestDedupRemovesAGenuineDuplicate(t *testing.T) {
victim := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
survivor := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
if err := newDedupTask().Execute(context.Background(), dedupParams(victim.address(), survivor.address(), 0)); err != nil {
t.Fatalf("dedup of a real duplicate should succeed, got %v", err)
}
if victim.has(dedupTestVolumeID, 0) {
t.Error("dedup left the duplicate in place; capacity is not reclaimed")
}
if !survivor.has(dedupTestVolumeID, 0) {
t.Error("dedup removed the surviving copy instead of the duplicate")
}
}
// An unreachable peer is not evidence of a surviving copy. Treating a failed
// query as "the other side has it" is how a network blip becomes data loss.
func TestDedupRefusesWhenThePeerCannotBeQueried(t *testing.T) {
holder := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
unreachable := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
unreachable.stop() // peer down; its inventory is unknown, not empty
err := newDedupTask().Execute(context.Background(), dedupParams(holder.address(), unreachable.address(), 0))
if holder.has(dedupTestVolumeID, 0) {
return // kept the shard while the peer's state is unknown
}
t.Fatalf("dedup deleted shard %d.0 while the peer was unreachable (deleted=%v, err=%v)",
dedupTestVolumeID, holder.deletedShards(), err)
}
// Volume ids are allocated cluster-wide, but the inventory RPC is keyed by
// volume id alone, so a server holding the same number for a different
// collection answers "yes, I have that shard" to a question about this one.
// Accepting that would delete the last real copy on the strength of an
// unrelated volume.
func TestDedupRefusesWhenTheKeepNodeHoldsAnotherCollection(t *testing.T) {
holder := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
// Same volume id, different collection: a plausible-looking but wrong match.
impostor := startFakeEcVolumeServerInCollection(t, "morro-agudo-01", dedupTestVolumeID, 0)
err := newDedupTask().Execute(context.Background(), dedupParams(holder.address(), impostor.address(), 0))
if holder.has(dedupTestVolumeID, 0) {
return // refused: the keep node's shard belongs to another collection
}
t.Fatalf("dedup deleted shard %d.0 after matching another collection's volume (deleted=%v, err=%v)",
dedupTestVolumeID, holder.deletedShards(), err)
}

View File

@@ -0,0 +1,331 @@
package ec_balance
import (
"context"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
storagetypes "github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/topology/balancer"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Detection builds an EC balance topology snapshot from the cluster's active
// topology, runs the shared ecbalancer planner, and converts the planned moves
// into worker task proposals. The balancing policy lives in
// weed/storage/erasure_coding/ecbalancer, shared with the shell ec.balance
// command so the two cannot drift.
func Detection(
ctx context.Context,
metrics []*types.VolumeHealthMetrics,
clusterInfo *types.ClusterInfo,
config base.TaskConfig,
maxResults int,
) ([]*types.TaskDetectionResult, bool, error) {
if !config.IsEnabled() {
return nil, false, nil
}
ecConfig := config.(*Config)
if maxResults < 0 {
maxResults = 0
}
if clusterInfo == nil || clusterInfo.ActiveTopology == nil {
return nil, false, fmt.Errorf("active topology not available for EC balance detection")
}
topoInfo := clusterInfo.ActiveTopology.GetTopologyInfo()
if topoInfo == nil {
return nil, false, fmt.Errorf("topology info not available")
}
allowedCollections, err := wildcard.CompileCollectionMatcher(ecConfig.CollectionFilter)
if err != nil {
return nil, false, err
}
topo, nodeCount, volumeRatio := buildBalancerTopology(topoInfo, ecConfig, allowedCollections)
if nodeCount < ecConfig.MinServerCount {
glog.V(1).Infof("EC balance: only %d servers, need at least %d", nodeCount, ecConfig.MinServerCount)
return nil, false, nil
}
replicaPlacement := resolveReplicaPlacement(ecConfig, clusterInfo)
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, false, err
}
}
// Canonical disk type for placement/execution: "hdd" -> "" (HardDriveType),
// matching the topology's disk keys and the volume server's move RPCs.
normalizedDiskType := storagetypes.ToDiskType(ecConfig.DiskType).String()
moves := ecbalancer.Plan(topo, ecbalancer.Options{
DiskType: normalizedDiskType,
ImbalanceThreshold: ecConfig.ImbalanceThreshold,
ReplicaPlacement: replicaPlacement,
Ratio: func(collection string) (int, int) {
return resolveECRatio(clusterInfo, collection)
},
// Prefer each volume's own heartbeat-reported ratio over the collection
// default so a mixed-ratio collection is spread per volume; 0 defers to
// resolveECRatio (and is the always-0 OSS case).
VolumeRatio: volumeRatio,
// Move incrementally across detection cycles rather than draining a rack
// in one batch; the scheduler re-evaluates each cycle.
GlobalMaxMovesPerRack: 10,
// Balance heterogeneous-capacity racks by fractional fullness.
GlobalUtilizationBased: true,
})
if len(moves) == 0 {
return nil, false, nil
}
hasMore := false
if maxResults > 0 && len(moves) > maxResults {
moves = moves[:maxResults]
hasMore = true
}
now := time.Now()
results := make([]*types.TaskDetectionResult, 0, len(moves))
for i, m := range moves {
taskID := fmt.Sprintf("ec_balance_%d_%d_%s_%s_%d_%d",
m.VolumeID, m.ShardID, m.SourceNode, m.TargetNode, now.UnixNano(), i)
results = append(results, &types.TaskDetectionResult{
TaskID: taskID,
TaskType: types.TaskTypeECBalance,
VolumeID: m.VolumeID,
Server: m.SourceNode,
Collection: m.Collection,
Priority: movePhasePriority(m.Phase),
Reason: fmt.Sprintf("EC shard %d.%d %s: %s → %s",
m.VolumeID, m.ShardID, m.Phase, m.SourceNode, m.TargetNode),
ScheduleAt: now,
TypedParams: &worker_pb.TaskParams{
TaskId: taskID,
VolumeId: m.VolumeID,
Collection: m.Collection,
Sources: []*worker_pb.TaskSource{{
Node: m.SourceNode,
DiskId: m.SourceDisk,
Rack: m.SourceRack,
ShardIds: []uint32{uint32(m.ShardID)},
}},
Targets: []*worker_pb.TaskTarget{{
Node: m.TargetNode,
DiskId: m.TargetDisk,
Rack: m.TargetRack,
ShardIds: []uint32{uint32(m.ShardID)},
}},
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
DiskType: normalizedDiskType,
DedupKeepNode: m.KeepNode,
TimeoutSeconds: 600,
IoBytePerSecond: ecConfig.IoBytePerSecond,
},
},
},
})
}
glog.V(1).Infof("EC balance detection: %d moves proposed", len(results))
return results, hasMore, nil
}
// buildBalancerTopology builds an ecbalancer.Topology from the master topology,
// applying the data-center, disk-type, and collection filters. Rack keys are
// dc:rack composites to avoid cross-DC name collisions. Per-disk free capacity
// is split evenly from the node total because the wire collapses same-type disks.
// Returns the topology, the number of eligible nodes (for MinServerCount), and a
// per-volume ratio lookup built from each shard's heartbeat (0,0 when unreported,
// e.g. always in OSS) which Plan prefers over the collection ratio for mixed-ratio
// clusters.
func buildBalancerTopology(topoInfo *master_pb.TopologyInfo, config *Config, allowedCollections *wildcard.CollectionMatcher) (*ecbalancer.Topology, int, func(collection string, vid uint32) (int, int)) {
topo := ecbalancer.NewTopology()
type volRatioKey struct {
collection string
vid uint32
}
volRatios := make(map[volRatioKey][2]int)
// Normalize the disk-type filter: "hdd" (and the default "") map to the
// HardDriveType, which the topology reports under the empty-string key. Keep a
// separate "filter requested" flag so a configured "hdd" still filters to HDD
// disks instead of being mistaken for "all disk types".
filterByDiskType := config.DiskType != ""
wantDiskType := storagetypes.ToDiskType(config.DiskType).String()
nodeCount := 0
for _, dc := range topoInfo.DataCenterInfos {
if config.DataCenterFilter != "" {
matchers := wildcard.CompileWildcardMatchers(config.DataCenterFilter)
if !wildcard.MatchesAnyWildcard(matchers, dc.Id) {
continue
}
}
for _, rack := range dc.RackInfos {
rackKey := dc.Id + ":" + rack.Id
for _, dn := range rack.DataNodeInfos {
freeSlots := 0
diskTypeOf := make(map[uint32]string) // physical disk_id -> disk type
diskShardCount := make(map[uint32]int)
fullDiskTypes := make(map[string]bool) // physically near-full, ineligible as a target
hasMatchingDisk := false
for diskType, diskInfo := range dn.DiskInfos {
if filterByDiskType && diskType != wantDiskType {
continue
}
hasMatchingDisk = true
// Don't place EC shards on a physically near-full disk, even if slot
// math says it has room (an over-set maxVolumeCount hides real
// fullness). Statfs free bytes already include EC shard files.
if balancer.DiskTooFullAfter(diskInfo.DiskTotalBytes, diskInfo.DiskFreeBytes, 0, balancer.DefaultMaxDiskUsagePercent) {
fullDiskTypes[diskType] = true
}
fs := int(diskInfo.MaxVolumeCount-diskInfo.VolumeCount)*erasure_coding.DataShardsCount - countEcShards(diskInfo.EcShardInfos)
if fs > 0 && !fullDiskTypes[diskType] {
freeSlots += fs
}
// Discover physical disks from regular volumes too, so an
// EC-empty disk is still a candidate destination.
for _, vi := range diskInfo.VolumeInfos {
if _, ok := diskTypeOf[vi.DiskId]; !ok {
diskTypeOf[vi.DiskId] = diskType
}
}
for _, eci := range diskInfo.EcShardInfos {
if _, ok := diskTypeOf[eci.DiskId]; !ok {
diskTypeOf[eci.DiskId] = diskType
}
// Disk occupancy counts ALL volumes' shards (capacity model),
// independent of the collection filter below.
diskShardCount[eci.DiskId] += erasure_coding.GetShardCount(eci)
}
}
if !hasMatchingDisk {
continue
}
node := topo.AddNode(dn.Id, dc.Id, rackKey, freeSlots)
// Group servers sharing a host so a volume's shards spread across
// machines, not just nodes (servers on one host are one fault domain).
node.SetHost(pb.NewServerAddressFromDataNode(dn).ToHost())
// Spread the node's free slots only over its non-full disks, so a
// physically full disk type doesn't dilute the share the usable disks
// advertise (its own disks get 0 below). The total is preserved.
nonFullDiskCount := 0
for _, dt := range diskTypeOf {
if !fullDiskTypes[dt] {
nonFullDiskCount++
}
}
perDiskFree := 0
if nonFullDiskCount > 0 && freeSlots > 0 {
perDiskFree = freeSlots / nonFullDiskCount
}
for diskID, diskType := range diskTypeOf {
diskFree := perDiskFree
if fullDiskTypes[diskType] {
diskFree = 0
}
node.AddDisk(diskID, diskType, diskFree, diskShardCount[diskID])
}
// Add shards only for volumes whose collection passes the filter;
// those are the volumes the planner will balance.
for diskType, diskInfo := range dn.DiskInfos {
if filterByDiskType && diskType != wantDiskType {
continue
}
for _, eci := range diskInfo.EcShardInfos {
if !allowedCollections.Matches(eci.Collection) {
continue
}
node.AddShards(eci.Id, eci.Collection, eci.DiskId, erasure_coding.ShardBits(eci.EcIndexBits))
if d, p := ecbalancer.VolumeShardRatio(eci); d > 0 || p > 0 {
volRatios[volRatioKey{eci.Collection, eci.Id}] = [2]int{d, p}
}
}
}
nodeCount++
}
}
}
volumeRatio := func(collection string, vid uint32) (int, int) {
r := volRatios[volRatioKey{collection, vid}]
return r[0], r[1]
}
return topo, nodeCount, volumeRatio
}
// resolveECRatio returns the (dataShards, parityShards) for a collection from the
// admin EC config snapshot when present, else the local default. This keeps the
// enterprise-only custom-ratio plumbing out of the shared planner.
func resolveECRatio(_ *types.ClusterInfo, _ string) (int, int) {
// Custom EC ratios are an enterprise feature; OSS uses the standard scheme.
return normalizeECShardCounts(0, 0)
}
// resolveReplicaPlacement picks the EC shard replica placement constraint: an
// explicit config value wins; otherwise it falls back to the master's default
// replication (matching the shell ec.balance default). A missing, invalid, or
// zero-replication value yields nil, meaning even spread / no constraint.
func resolveReplicaPlacement(ecConfig *Config, clusterInfo *types.ClusterInfo) *super_block.ReplicaPlacement {
clusterDefault := ""
if clusterInfo != nil {
clusterDefault = clusterInfo.DefaultReplicaPlacement
}
return super_block.ResolveReplicaPlacement(ecConfig.ReplicaPlacement, clusterDefault)
}
func normalizeECShardCounts(dataShards, parityShards int) (int, int) {
if dataShards <= 0 {
dataShards = erasure_coding.DataShardsCount
}
if parityShards <= 0 {
parityShards = erasure_coding.ParityShardsCount
}
return dataShards, parityShards
}
func countEcShards(ecShardInfos []*master_pb.VolumeEcShardInformationMessage) int {
count := 0
for _, eci := range ecShardInfos {
count += erasure_coding.GetShardCount(eci)
}
return count
}
func movePhasePriority(phase string) types.TaskPriority {
switch phase {
case "dedup":
return types.TaskPriorityHigh
case "cross_rack":
return types.TaskPriorityMedium
default:
return types.TaskPriorityLow
}
}

View File

@@ -0,0 +1,156 @@
package ec_balance
import (
"context"
"net"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// The EC balance policy itself is tested in the shared ecbalancer package; these
// tests cover the worker adapter: building the planner topology from the master
// topology (filters, capacity) and the Detection entry point.
func ecTopo(node1Collection string) *master_pb.TopologyInfo {
node1 := &master_pb.DataNodeInfo{
Id: "node1",
DiskInfos: map[string]*master_pb.DiskInfo{
"": {Type: "", MaxVolumeCount: 100, EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
{Id: 100, Collection: node1Collection, DiskId: 0, EcIndexBits: 0x3FFF}, // 14 shards
}},
},
}
node2 := &master_pb.DataNodeInfo{
Id: "node2",
DiskInfos: map[string]*master_pb.DiskInfo{"": {Type: "", MaxVolumeCount: 100}},
}
return &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{
{Id: "rack1", DataNodeInfos: []*master_pb.DataNodeInfo{node1}},
{Id: "rack2", DataNodeInfos: []*master_pb.DataNodeInfo{node2}},
},
}},
}
}
func TestBuildBalancerTopology(t *testing.T) {
config := NewDefaultConfig()
topo, nodeCount, _ := buildBalancerTopology(ecTopo("col1"), config, nil)
if nodeCount != 2 {
t.Fatalf("nodeCount = %d, want 2", nodeCount)
}
moves := ecbalancer.Plan(topo, ecbalancer.Options{ImbalanceThreshold: 0.01})
if len(moves) == 0 {
t.Error("expected cross-rack moves for an all-on-one-rack volume")
}
}
// TestBuildBalancerTopologyGroupsByHost: two volume servers on host 10.0.0.1
// (different ports) plus three other hosts, a 10+4 volume concentrated on the
// 10.0.0.1 machine. Four machines is enough to spread within parity, so after
// planning the 10.0.0.1 machine must hold <=4 shards of the volume -- which only
// holds if its two ports are grouped into one machine (host wired into the build).
func TestBuildBalancerTopologyGroupsByHost(t *testing.T) {
mkNode := func(id string, bits uint32) *master_pb.DataNodeInfo {
di := &master_pb.DiskInfo{Type: "", MaxVolumeCount: 100}
if bits != 0 {
di.EcShardInfos = []*master_pb.VolumeEcShardInformationMessage{{Id: 100, Collection: "col1", DiskId: 0, EcIndexBits: bits}}
}
return &master_pb.DataNodeInfo{Id: id, DiskInfos: map[string]*master_pb.DiskInfo{"": di}}
}
topoInfo := &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{Id: "rack1", DataNodeInfos: []*master_pb.DataNodeInfo{
mkNode("10.0.0.1:8080", 0x007F), // shards 0-6 on host 10.0.0.1
mkNode("10.0.0.1:8081", 0x3F80), // shards 7-13 on host 10.0.0.1
mkNode("10.0.0.2:8080", 0),
mkNode("10.0.0.3:8080", 0),
mkNode("10.0.0.4:8080", 0),
}}},
}},
}
topo, _, _ := buildBalancerTopology(topoInfo, NewDefaultConfig(), nil)
moves := ecbalancer.Plan(topo, ecbalancer.Options{ImbalanceThreshold: 0.01})
host := func(nodeID string) string { h, _, _ := net.SplitHostPort(nodeID); return h }
count := map[string]int{"10.0.0.1": 14}
for _, m := range moves {
if m.VolumeID != 100 {
continue
}
count[host(m.SourceNode)]--
if m.SourceNode != m.TargetNode { // non-dedup move
count[host(m.TargetNode)]++
}
}
if count["10.0.0.1"] > 4 {
t.Errorf("machine 10.0.0.1 holds %d shards of the volume after balancing, want <=4 (host grouping not applied)", count["10.0.0.1"])
}
}
func TestBuildBalancerTopologyCollectionFilter(t *testing.T) {
config := NewDefaultConfig()
config.CollectionFilter = "other" // does not match the volume's collection
allowed, err := wildcard.CompileCollectionMatcher(config.CollectionFilter)
if err != nil {
t.Fatalf("CompileCollectionMatcher: %v", err)
}
topo, nodeCount, _ := buildBalancerTopology(ecTopo("col1"), config, allowed)
if nodeCount != 2 {
t.Fatalf("nodeCount = %d, want 2", nodeCount)
}
if moves := ecbalancer.Plan(topo, ecbalancer.Options{ImbalanceThreshold: 0.01}); len(moves) != 0 {
t.Errorf("filtered-out collection should produce no moves, got %d", len(moves))
}
}
func TestDetectionDisabled(t *testing.T) {
config := NewDefaultConfig()
config.Enabled = false
results, hasMore, err := Detection(context.Background(), nil, nil, config, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hasMore {
t.Error("expected hasMore=false")
}
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
}
func TestDetectionNilTopology(t *testing.T) {
config := NewDefaultConfig()
clusterInfo := &types.ClusterInfo{ActiveTopology: nil}
if _, _, err := Detection(context.Background(), nil, clusterInfo, config, 0); err == nil {
t.Fatal("expected error for nil topology")
}
}
func TestMovePhasePriority(t *testing.T) {
cases := map[string]types.TaskPriority{
"dedup": types.TaskPriorityHigh,
"cross_rack": types.TaskPriorityMedium,
"within_rack": types.TaskPriorityLow,
"global": types.TaskPriorityLow,
}
for phase, want := range cases {
if got := movePhasePriority(phase); got != want {
t.Errorf("movePhasePriority(%q) = %v, want %v", phase, got, want)
}
}
}
// keep the erasure_coding import meaningful for future adapter tests
var _ = erasure_coding.DataShardsCount

View File

@@ -0,0 +1,230 @@
package ec_balance
import (
"context"
"fmt"
"math"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation/volume_move"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
"github.com/seaweedfs/seaweedfs/weed/worker/types/base"
"google.golang.org/grpc"
)
// ECBalanceTask implements a single EC shard move operation.
// The move sequence — copy+mount on dest, verify the dest registered the
// shards, then unmount+delete on the source — is shared with the shell's
// ec.balance command via weed/operation/volume_move.
type ECBalanceTask struct {
*base.BaseTask
volumeID uint32
collection string
grpcDialOption grpc.DialOption
progress uint64 // atomic; stores float64 bits via math.Float64bits
reporting int32 // atomic; re-entry guard to prevent recursive reportProgress calls
}
// NewECBalanceTask creates a new EC balance task instance
func NewECBalanceTask(id string, volumeID uint32, collection string, grpcDialOption grpc.DialOption) *ECBalanceTask {
return &ECBalanceTask{
BaseTask: base.NewBaseTask(id, types.TaskTypeECBalance),
volumeID: volumeID,
collection: collection,
grpcDialOption: grpcDialOption,
}
}
// Execute performs the EC shard move operation.
func (t *ECBalanceTask) Execute(ctx context.Context, params *worker_pb.TaskParams) error {
if params == nil {
return fmt.Errorf("task parameters are required")
}
if len(params.Sources) == 0 || len(params.Targets) == 0 {
return fmt.Errorf("sources and targets are required for EC shard move")
}
if len(params.Sources) > 1 || len(params.Targets) > 1 {
return fmt.Errorf("batch EC shard moves not supported: got %d sources and %d targets, expected 1 each", len(params.Sources), len(params.Targets))
}
source := params.Sources[0]
target := params.Targets[0]
if len(source.ShardIds) == 0 || len(target.ShardIds) == 0 {
return fmt.Errorf("shard IDs are required in sources and targets")
}
sourceAddr := pb.ServerAddress(source.Node)
targetAddr := pb.ServerAddress(target.Node)
// Range-check before the uint8 narrowing in Uint32ToShardIds: a malformed
// id like 259 would otherwise alias shard 3 and copy/delete a real,
// unrelated shard.
if err := checkShardIdRange(source.ShardIds); err != nil {
return err
}
shardIds := erasure_coding.Uint32ToShardIds(source.ShardIds)
ecParams := params.GetEcBalanceParams()
// Apply configured timeout to the context for all RPC operations
if ecParams != nil && ecParams.TimeoutSeconds > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(ecParams.TimeoutSeconds)*time.Second)
defer cancel()
}
isDedupDelete := ecParams != nil && isDedupPhase(params)
// Guard against a same-node, cross-disk "move". The shared mover skips the
// copy when source and target addresses match, but the EC shard delete is
// node-wide (it removes the shard from every disk on the node), so this
// sequence would erase the shard after never copying it. EC shards also
// cannot be relocated between disks of one node via these RPCs, so such a
// move is meaningless. Reject it rather than lose data.
if source.Node == target.Node && source.DiskId != target.DiskId {
return fmt.Errorf("refusing same-node cross-disk EC shard move for volume %d shard(s) %v on %s (source disk %d, target disk %d): EC shard delete is node-wide and would erase the shard after a skipped copy",
params.VolumeId, source.ShardIds, source.Node, source.DiskId, target.DiskId)
}
glog.Infof("EC balance: moving shard(s) %v of volume %d from %s to %s",
source.ShardIds, params.VolumeId, source.Node, target.Node)
mover := volume_move.NewMover(t.grpcDialOption)
// For dedup, we only unmount+delete from source (no copy needed)
if isDedupDelete {
// Nothing is copied first, so the shard surviving elsewhere is the
// only thing making this safe — and the plan asserting so is not
// evidence. The topology can name a location that holds nothing, and
// deleting on that basis removes the last copy. Confirm the keep node
// has it before deleting here.
if err := t.verifyShardsOnKeepNode(ctx, params.VolumeId, ecParams.GetDedupKeepNode(), source.ShardIds); err != nil {
return err
}
t.reportProgress(25.0, "Removing duplicate EC shard")
if err := mover.RemoveEcShards(ctx, needle.VolumeId(params.VolumeId), t.collection, sourceAddr, shardIds); err != nil {
return fmt.Errorf("remove duplicate shard: %w", err)
}
t.reportProgress(100.0, "Duplicate shard removed")
return nil
}
err := mover.MoveEcShards(ctx, volume_move.EcShardMove{
VolumeId: needle.VolumeId(params.VolumeId),
Collection: params.Collection,
ShardIds: shardIds,
Source: sourceAddr,
Target: targetAddr,
TargetDisk: target.DiskId,
}, volume_move.EcMoveOptions{
IoBytePerSecond: ecParams.GetIoBytePerSecond(),
Progress: t.reportProgress,
})
if err != nil {
return err
}
glog.Infof("EC balance: successfully moved shard(s) %v of volume %d from %s to %s",
source.ShardIds, params.VolumeId, source.Node, target.Node)
return nil
}
// verifyShardsOnKeepNode confirms the node the plan wants to keep the shard on
// actually has every shard about to be deleted elsewhere, for this collection.
func (t *ECBalanceTask) verifyShardsOnKeepNode(ctx context.Context, volumeID uint32, keepNode string, shardIDs []uint32) error {
if keepNode == "" {
return fmt.Errorf("refusing dedup delete of volume %d shard(s) %v: no keep node recorded, so no surviving copy can be confirmed", volumeID, shardIDs)
}
if err := erasure_coding.VerifyShardsOnServer(ctx, t.collection, volumeID,
string(pb.ServerAddress(keepNode)), shardIDs, t.grpcDialOption); err != nil {
return fmt.Errorf("refusing dedup delete: %w", err)
}
return nil
}
// Validate validates the task parameters.
// ECBalanceTask handles exactly one source→target shard move per execution.
func (t *ECBalanceTask) Validate(params *worker_pb.TaskParams) error {
if params == nil {
return fmt.Errorf("ECBalanceTask.Validate: TaskParams are required")
}
if len(params.Sources) != 1 {
return fmt.Errorf("ECBalanceTask.Validate: expected exactly 1 source, got %d", len(params.Sources))
}
if len(params.Targets) != 1 {
return fmt.Errorf("ECBalanceTask.Validate: expected exactly 1 target, got %d", len(params.Targets))
}
if len(params.Sources[0].ShardIds) == 0 {
return fmt.Errorf("ECBalanceTask.Validate: Sources[0].ShardIds is empty")
}
if len(params.Targets[0].ShardIds) == 0 {
return fmt.Errorf("ECBalanceTask.Validate: Targets[0].ShardIds is empty")
}
if err := checkShardIdRange(params.Sources[0].ShardIds); err != nil {
return fmt.Errorf("ECBalanceTask.Validate: %v", err)
}
if err := checkShardIdRange(params.Targets[0].ShardIds); err != nil {
return fmt.Errorf("ECBalanceTask.Validate: %v", err)
}
// A same-node, cross-disk move is unsafe: the node-wide EC shard delete would
// erase the shard after the skipped same-address copy. Such a move cannot be
// expressed by these RPCs anyway. Dedup (same node and disk) is allowed.
if params.Sources[0].Node == params.Targets[0].Node && params.Sources[0].DiskId != params.Targets[0].DiskId {
return fmt.Errorf("ECBalanceTask.Validate: refusing same-node cross-disk move on %s (source disk %d, target disk %d): EC shard delete is node-wide",
params.Sources[0].Node, params.Sources[0].DiskId, params.Targets[0].DiskId)
}
return nil
}
// EstimateTime estimates the time for an EC shard move
func (t *ECBalanceTask) EstimateTime(params *worker_pb.TaskParams) time.Duration {
return 30 * time.Second
}
// GetProgress returns current progress
func (t *ECBalanceTask) GetProgress() float64 {
return math.Float64frombits(atomic.LoadUint64(&t.progress))
}
// reportProgress updates the stored progress and reports it via the callback
func (t *ECBalanceTask) reportProgress(progress float64, stage string) {
if !atomic.CompareAndSwapInt32(&t.reporting, 0, 1) {
return
}
defer atomic.StoreInt32(&t.reporting, 0)
atomic.StoreUint64(&t.progress, math.Float64bits(progress))
t.ReportProgressWithStage(progress, stage)
glog.Infof("EC balance volume %d: [%.2f] %s", t.volumeID, progress, stage)
}
// checkShardIdRange rejects shard ids that would alias a real shard when
// narrowed to the uint8 ShardId (e.g. 259 → 3).
func checkShardIdRange(ids []uint32) error {
for _, id := range ids {
if id >= erasure_coding.MaxShardCount {
return fmt.Errorf("shard id %d out of range (max %d)", id, erasure_coding.MaxShardCount-1)
}
}
return nil
}
// isDedupPhase checks if this is a dedup-phase task: an unmount+delete on a
// single location, encoded by detection as source==target on the same node AND
// the same disk. Comparing the disk too is essential — VolumeEcShardsDelete is
// node-wide (it removes the shard from every disk on the node), so a same-node
// but cross-disk request must NOT be treated as a benign dedup; see Validate
// and Execute, which reject it outright.
func isDedupPhase(params *worker_pb.TaskParams) bool {
if len(params.Sources) > 0 && len(params.Targets) > 0 {
s, t := params.Sources[0], params.Targets[0]
return s.Node == t.Node && s.DiskId == t.DiskId
}
return false
}

View File

@@ -0,0 +1,27 @@
package ec_balance
import (
"context"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
)
func TestECBalanceTaskRejectsOutOfRangeShardIds(t *testing.T) {
// ShardId is a uint8; an unchecked id like 259 would alias shard 3 and
// copy/delete a real, unrelated shard.
params := &worker_pb.TaskParams{
VolumeId: 7,
Sources: []*worker_pb.TaskSource{{Node: "src:8080", ShardIds: []uint32{259}}},
Targets: []*worker_pb.TaskTarget{{Node: "dst:8080", ShardIds: []uint32{259}}},
}
task := NewECBalanceTask("t1", 7, "c1", nil)
if err := task.Validate(params); err == nil || !strings.Contains(err.Error(), "out of range") {
t.Fatalf("Validate: expected out-of-range rejection, got: %v", err)
}
if err := task.Execute(context.Background(), params); err == nil || !strings.Contains(err.Error(), "out of range") {
t.Fatalf("Execute: expected out-of-range rejection, got: %v", err)
}
}

View File

@@ -0,0 +1,140 @@
package ec_balance
import (
"context"
"fmt"
"net"
"sync"
"testing"
"google.golang.org/grpc"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
)
// fakeEcVolumeServer is a volume server that only knows about EC shard
// inventory. It exists so the destructive half of a balance job can be driven
// in-process: what a real volume server has on disk is the thing the master's
// topology can be wrong about, and being wrong about it is what makes a delete
// unsafe. Holding that state here lets a test say "the topology claims this,
// the disk holds that" and watch what the job does.
type fakeEcVolumeServer struct {
volume_server_pb.UnimplementedVolumeServerServer
mu sync.Mutex
// shards actually present on this server's disks, per volume.
shards map[uint32]map[uint32]bool
// collection the held volume belongs to. Reported back on the inventory so
// a caller can tell this server's volume N from another collection's N.
collection string
// deleted/unmounted record what the job asked for, so a test can assert on
// the request even when it was a no-op against the inventory.
deleted []string
unmounted []string
// grpcAddr is host:port; the code under test dials by address string.
grpcAddr string
stop func()
}
func (f *fakeEcVolumeServer) VolumeEcShardsInfo(ctx context.Context, req *volume_server_pb.VolumeEcShardsInfoRequest) (*volume_server_pb.VolumeEcShardsInfoResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
resp := &volume_server_pb.VolumeEcShardsInfoResponse{}
for sid := range f.shards[req.VolumeId] {
resp.EcShardInfos = append(resp.EcShardInfos, &volume_server_pb.EcShardInfo{
VolumeId: req.VolumeId,
ShardId: sid,
Collection: f.collection,
})
}
return resp, nil
}
func (f *fakeEcVolumeServer) VolumeEcShardsDelete(ctx context.Context, req *volume_server_pb.VolumeEcShardsDeleteRequest) (*volume_server_pb.VolumeEcShardsDeleteResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, sid := range req.ShardIds {
f.deleted = append(f.deleted, fmt.Sprintf("%d.%d", req.VolumeId, sid))
delete(f.shards[req.VolumeId], sid)
}
return &volume_server_pb.VolumeEcShardsDeleteResponse{}, nil
}
func (f *fakeEcVolumeServer) VolumeEcShardsUnmount(ctx context.Context, req *volume_server_pb.VolumeEcShardsUnmountRequest) (*volume_server_pb.VolumeEcShardsUnmountResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, sid := range req.ShardIds {
f.unmounted = append(f.unmounted, fmt.Sprintf("%d.%d", req.VolumeId, sid))
}
return &volume_server_pb.VolumeEcShardsUnmountResponse{}, nil
}
// has reports whether the shard is still on this server's disks.
func (f *fakeEcVolumeServer) has(volumeID, shardID uint32) bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.shards[volumeID][shardID]
}
func (f *fakeEcVolumeServer) deletedShards() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.deleted...)
}
// startFakeEcVolumeServer listens on a loopback port and serves the EC subset
// of the volume server API. present lists the shards this server really holds.
func startFakeEcVolumeServer(t *testing.T, volumeID uint32, present ...uint32) *fakeEcVolumeServer {
return startFakeEcVolumeServerInCollection(t, dedupTestCollection, volumeID, present...)
}
// startFakeEcVolumeServerInCollection is the same, for a named collection, so a
// test can stand up two servers holding the same volume id in different ones.
func startFakeEcVolumeServerInCollection(t *testing.T, collection string, volumeID uint32, present ...uint32) *fakeEcVolumeServer {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
f := &fakeEcVolumeServer{
shards: map[uint32]map[uint32]bool{volumeID: {}},
collection: collection,
grpcAddr: listener.Addr().String(),
}
for _, sid := range present {
f.shards[volumeID][sid] = true
}
server := grpc.NewServer()
volume_server_pb.RegisterVolumeServerServer(server, f)
go func() { _ = server.Serve(listener) }()
f.stop = server.Stop
t.Cleanup(server.Stop)
return f
}
// address returns a node id in the "host:httpPort.grpcPort" form the cluster
// uses (e.g. mg01-s3-intelistor-15:8087.18087), so ToGrpcAddress resolves to
// this fake's listener. The http port is unused and only has to parse.
func (f *fakeEcVolumeServer) address() string {
host, port, err := net.SplitHostPort(f.grpcAddr)
if err != nil {
return f.grpcAddr
}
return fmt.Sprintf("%s:1.%s", host, port)
}
// shardBitsOf is a small readability helper for asserting inventory.
func shardBitsOf(f *fakeEcVolumeServer, volumeID uint32) erasure_coding.ShardBits {
f.mu.Lock()
defer f.mu.Unlock()
var b erasure_coding.ShardBits
for sid := range f.shards[volumeID] {
b = b.Set(erasure_coding.ShardId(sid))
}
return b
}

View File

@@ -0,0 +1,335 @@
package ec_balance
import (
"context"
"sort"
"testing"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// In-process (no real cluster) tests of the detection/planning path against a
// topology shaped the way the master reports a multi-disk cluster: several
// same-type physical disks on a node collapse into a single DiskInfo, with each
// shard's real DiskId surviving only in the per-shard records (issue 9593). They
// run the master-wire-format -> ActiveTopology -> Detection path, then simulate
// executing the planned moves with the volume server's actual semantics
// (VolumeEcShardsDelete is node-wide) to assert no EC shard is ever lost. The
// real-cluster end-to-end equivalent lives in test/erasure_coding.
// nodeSpec describes one volume server: its rack and the volume's shards per
// physical disk (diskID -> shard ids).
type nodeSpec struct {
id string
rack string
disks map[uint32][]int
}
const integDisksPerNode = 6
// buildMasterTopology renders nodeSpecs into a *master_pb.TopologyInfo exactly as
// the master would: one DiskInfo per node keyed by disk type (""), carrying one
// EcShardInfo per (volume, physical disk) plus regular VolumeInfos so every
// physical disk is discoverable even when it holds no EC shards.
func buildMasterTopology(collection string, vid uint32, maxVolPerDisk int, specs []nodeSpec) *master_pb.TopologyInfo {
rackByID := map[string]*master_pb.RackInfo{}
var rackOrder []string
for _, spec := range specs {
var ecShards []*master_pb.VolumeEcShardInformationMessage
for diskID, shards := range spec.disks {
var bits erasure_coding.ShardBits
for _, s := range shards {
bits = bits.Set(erasure_coding.ShardId(s))
}
ecShards = append(ecShards, &master_pb.VolumeEcShardInformationMessage{
Id: vid,
Collection: collection,
DiskId: diskID,
EcIndexBits: uint32(bits),
})
}
// Expose all physical disks (incl. EC-empty ones) via regular volumes.
var volInfos []*master_pb.VolumeInformationMessage
for d := uint32(0); d < integDisksPerNode; d++ {
volInfos = append(volInfos, &master_pb.VolumeInformationMessage{Id: 90000 + d, DiskId: d})
}
dn := &master_pb.DataNodeInfo{
Id: spec.id,
DiskInfos: map[string]*master_pb.DiskInfo{
"": {
Type: "",
MaxVolumeCount: int64(maxVolPerDisk * integDisksPerNode),
VolumeCount: int64(integDisksPerNode),
EcShardInfos: ecShards,
VolumeInfos: volInfos,
},
},
}
r, ok := rackByID[spec.rack]
if !ok {
r = &master_pb.RackInfo{Id: spec.rack}
rackByID[spec.rack] = r
rackOrder = append(rackOrder, spec.rack)
}
r.DataNodeInfos = append(r.DataNodeInfos, dn)
}
dc := &master_pb.DataCenterInfo{Id: "dc1"}
for _, rk := range rackOrder {
dc.RackInfos = append(dc.RackInfos, rackByID[rk])
}
return &master_pb.TopologyInfo{Id: "integ", DataCenterInfos: []*master_pb.DataCenterInfo{dc}}
}
// runDetection wires the topology through a real ActiveTopology and runs Detection.
func runDetection(t *testing.T, topoInfo *master_pb.TopologyInfo, cfg *Config) []*types.TaskDetectionResult {
t.Helper()
at := topology.NewActiveTopology(0)
if err := at.UpdateTopology(topoInfo); err != nil {
t.Fatalf("UpdateTopology: %v", err)
}
results, _, err := Detection(context.Background(), nil, &types.ClusterInfo{ActiveTopology: at}, cfg, 0)
if err != nil {
t.Fatalf("Detection: %v", err)
}
return results
}
// shardModel is nodeID -> diskID -> set of shard ids for one volume.
type shardModel map[string]map[uint32]map[int]bool
func modelFromSpecs(specs []nodeSpec) shardModel {
m := make(shardModel)
for _, spec := range specs {
m[spec.id] = make(map[uint32]map[int]bool)
for diskID, shards := range spec.disks {
set := make(map[int]bool)
for _, s := range shards {
set[s] = true
}
m[spec.id][diskID] = set
}
}
return m
}
func (m shardModel) distinctShards() []int {
seen := map[int]bool{}
for _, disks := range m {
for _, set := range disks {
for s := range set {
seen[s] = true
}
}
}
out := make([]int, 0, len(seen))
for s := range seen {
out = append(out, s)
}
sort.Ints(out)
return out
}
// applyMovesRealistically replays planned moves with the volume server's actual
// behavior: a move copies the shard to the destination disk, then deletes it on
// the source node — and EC shard delete is node-wide (removes the shard from
// every disk on the source node). A dedup move (same node+disk) is delete-only.
func (m shardModel) apply(results []*types.TaskDetectionResult) {
nodeWideDelete := func(node string, shard int) {
for diskID := range m[node] {
delete(m[node][diskID], shard)
}
}
for _, r := range results {
p := r.TypedParams
if p == nil || len(p.Sources) == 0 || len(p.Targets) == 0 {
continue
}
src, dst := p.Sources[0], p.Targets[0]
if len(src.ShardIds) == 0 {
continue
}
shard := int(src.ShardIds[0])
dedup := src.Node == dst.Node && src.DiskId == dst.DiskId
if !dedup {
if m[dst.Node] == nil {
m[dst.Node] = make(map[uint32]map[int]bool)
}
if m[dst.Node][dst.DiskId] == nil {
m[dst.Node][dst.DiskId] = make(map[int]bool)
}
m[dst.Node][dst.DiskId][shard] = true
}
nodeWideDelete(src.Node, shard)
}
}
// TestMultiDiskBalanceNeverLosesShards is the core regression for
// issue 9593: a freshly-encoded volume on a 3-node, 6-disk-per-node cluster,
// balanced and then concentrated, must never lose a shard when the planned moves
// are executed with real node-wide-delete semantics.
func TestMultiDiskBalanceNeverLosesShards(t *testing.T) {
cfg := NewDefaultConfig()
cfg.ImbalanceThreshold = 0.0 // balance to even; surface any move the planner makes
cases := []struct {
name string
specs []nodeSpec
}{
{
// Healthy post-encode spread: 14 shards across 3 nodes (5/5/4), each
// node spreading its shards over distinct physical disks.
name: "balanced across disks",
specs: []nodeSpec{
{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0}, 1: {1}, 2: {2}, 3: {3}, 4: {4}}},
{id: "n2", rack: "rack1", disks: map[uint32][]int{0: {5}, 1: {6}, 2: {7}, 3: {8}, 4: {9}}},
{id: "n3", rack: "rack1", disks: map[uint32][]int{0: {10}, 1: {11}, 2: {12}, 3: {13}}},
},
},
{
// Worst case from the report: all 14 shards landed on one node's disks;
// the balancer must redistribute without losing any.
name: "concentrated on one node",
specs: []nodeSpec{
{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0, 1, 2}, 1: {3, 4, 5}, 2: {6, 7}, 3: {8, 9}, 4: {10, 11}, 5: {12, 13}}},
{id: "n2", rack: "rack2", disks: map[uint32][]int{}},
{id: "n3", rack: "rack3", disks: map[uint32][]int{}},
},
},
{
// Multi-rack healthy spread.
name: "spread across racks",
specs: []nodeSpec{
{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0, 1}, 1: {2, 3}, 2: {4}}},
{id: "n2", rack: "rack2", disks: map[uint32][]int{0: {5, 6}, 1: {7, 8}, 2: {9}}},
{id: "n3", rack: "rack3", disks: map[uint32][]int{0: {10, 11}, 1: {12, 13}}},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
topoInfo := buildMasterTopology("col1", 28, 50, tc.specs)
results := runDetection(t, topoInfo, cfg)
model := modelFromSpecs(tc.specs)
before := model.distinctShards()
for _, r := range results {
// No move may be a same-node cross-disk move (node-wide delete
// would erase the shard after a skipped copy).
p := r.TypedParams
s, d := p.Sources[0], p.Targets[0]
if s.Node == d.Node && s.DiskId != d.DiskId {
t.Errorf("unsafe same-node cross-disk move: vol %d shard %v on %s disk %d->%d", p.VolumeId, s.ShardIds, s.Node, s.DiskId, d.DiskId)
}
// Source disk must actually hold the shard being moved.
shard := int(s.ShardIds[0])
if !model[s.Node][s.DiskId][shard] && s.Node != d.Node {
t.Errorf("move sources vol %d shard %d from %s disk %d, which does not hold it", p.VolumeId, shard, s.Node, s.DiskId)
}
}
model.apply(results)
after := model.distinctShards()
if len(after) != len(before) {
t.Errorf("[%s] shard loss: had %v (%d), now %v (%d) after %d moves",
tc.name, before, len(before), after, len(after), len(results))
}
})
}
}
// TestConcentratedVolumeSpreadsAcrossNodesAndDisks asserts the
// remediation actually happens: a one-node-concentrated volume is redistributed
// to the other racks, landing on multiple distinct destination disks.
func TestConcentratedVolumeSpreadsAcrossNodesAndDisks(t *testing.T) {
cfg := NewDefaultConfig()
cfg.ImbalanceThreshold = 0.0
specs := []nodeSpec{
{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0, 1, 2}, 1: {3, 4, 5}, 2: {6, 7}, 3: {8, 9}, 4: {10, 11}, 5: {12, 13}}},
{id: "n2", rack: "rack2", disks: map[uint32][]int{}},
{id: "n3", rack: "rack3", disks: map[uint32][]int{}},
}
results := runDetection(t, buildMasterTopology("col1", 28, 50, specs), cfg)
if len(results) == 0 {
t.Fatal("expected redistribution moves for a one-node-concentrated volume")
}
destNodes := map[string]bool{}
destDisksByNode := map[string]map[uint32]bool{}
for _, r := range results {
d := r.TypedParams.Targets[0]
if d.Node == "n1" {
continue
}
destNodes[d.Node] = true
if destDisksByNode[d.Node] == nil {
destDisksByNode[d.Node] = map[uint32]bool{}
}
destDisksByNode[d.Node][d.DiskId] = true
}
if len(destNodes) < 2 {
t.Errorf("shards spread to only %d destination nodes, want both other racks", len(destNodes))
}
for node, disks := range destDisksByNode {
if len(disks) < 2 {
t.Errorf("destination %s received shards on only %d disk(s); expected spread across disks: %v", node, len(disks), disks)
}
}
}
// TestBuildBalancerTopologyNormalizesHddDiskType guards the disk-type filter:
// the master reports default-HDD disks under the empty-string key, so a config of
// "hdd" must match them (not filter everything out), while "ssd" must exclude them.
func TestBuildBalancerTopologyNormalizesHddDiskType(t *testing.T) {
specs := []nodeSpec{
{id: "n1", rack: "r1", disks: map[uint32][]int{0: {0, 1}}},
{id: "n2", rack: "r1", disks: map[uint32][]int{0: {2}}},
}
topoInfo := buildMasterTopology("c", 100, 50, specs)
if _, n, _ := buildBalancerTopology(topoInfo, &Config{DiskType: "hdd"}, nil); n != 2 {
t.Errorf("disk_type=hdd matched %d nodes on an all-HDD cluster, want 2 (hdd must map to the empty HDD key)", n)
}
if _, n, _ := buildBalancerTopology(topoInfo, &Config{DiskType: ""}, nil); n != 2 {
t.Errorf("disk_type=empty matched %d nodes, want 2 (all)", n)
}
if _, n, _ := buildBalancerTopology(topoInfo, &Config{DiskType: "ssd"}, nil); n != 0 {
t.Errorf("disk_type=ssd matched %d nodes on an all-HDD cluster, want 0", n)
}
}
// TestResolveReplicaPlacementFallsBackToMasterDefault verifies the worker mirrors
// the shell: explicit config wins, otherwise the master's default replication is
// the fallback, and an empty or zero-replication value means no constraint.
func TestResolveReplicaPlacementFallsBackToMasterDefault(t *testing.T) {
cases := []struct {
name string
configRP string
defaultRP string
wantApplied bool
}{
{"explicit config used", "010", "", true},
{"explicit config wins over default", "010", "100", true},
{"falls back to master default", "", "010", true},
{"zero master default = no constraint", "", "000", false},
{"empty everywhere = no constraint", "", "", false},
{"invalid value ignored", "", "nonsense", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rp := resolveReplicaPlacement(&Config{ReplicaPlacement: tc.configRP},
&types.ClusterInfo{DefaultReplicaPlacement: tc.defaultRP})
if (rp != nil) != tc.wantApplied {
t.Errorf("config=%q default=%q: applied=%v, want %v", tc.configRP, tc.defaultRP, rp != nil, tc.wantApplied)
}
})
}
}

View File

@@ -0,0 +1,58 @@
package ec_balance
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
)
// These cover the worker execution layer (ec_balance_task.go). The shard-balance
// policy and multi-disk placement are tested in the shared ecbalancer package.
// TestValidateRejectsSameNodeCrossDiskMove covers the data-loss trap of a
// same-node, cross-disk shard move. copyAndMountShard skips the copy when source
// and target addresses match, but VolumeEcShardsDelete is node-wide, so executing
// such a move would erase the shard. Validate must reject it, while allowing a
// cross-node move and a same-node/same-disk dedup.
func TestValidateRejectsSameNodeCrossDiskMove(t *testing.T) {
task := NewECBalanceTask("t1", 100, "col1", nil)
mk := func(srcNode string, srcDisk uint32, dstNode string, dstDisk uint32) *worker_pb.TaskParams {
return &worker_pb.TaskParams{
VolumeId: 100,
Sources: []*worker_pb.TaskSource{{Node: srcNode, DiskId: srcDisk, ShardIds: []uint32{3}}},
Targets: []*worker_pb.TaskTarget{{Node: dstNode, DiskId: dstDisk, ShardIds: []uint32{3}}},
}
}
if err := task.Validate(mk("node1", 0, "node1", 3)); err == nil {
t.Error("Validate accepted a same-node cross-disk move; it must reject it to avoid node-wide delete data loss")
}
if err := task.Validate(mk("node1", 0, "node2", 0)); err != nil {
t.Errorf("Validate rejected a legitimate cross-node move: %v", err)
}
if err := task.Validate(mk("node1", 2, "node1", 2)); err != nil {
t.Errorf("Validate rejected a same-node/same-disk dedup: %v", err)
}
}
// TestIsDedupPhaseRequiresSameDisk confirms dedup classification keys on both
// node and disk, so a same-node cross-disk request is never silently routed to
// the unmount+delete path.
func TestIsDedupPhaseRequiresSameDisk(t *testing.T) {
withParams := func(srcDisk, dstDisk uint32) *worker_pb.TaskParams {
return &worker_pb.TaskParams{
Sources: []*worker_pb.TaskSource{{Node: "node1", DiskId: srcDisk}},
Targets: []*worker_pb.TaskTarget{{Node: "node1", DiskId: dstDisk}},
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
EcBalanceParams: &worker_pb.EcBalanceTaskParams{},
},
}
}
if !isDedupPhase(withParams(2, 2)) {
t.Error("same node and disk should be a dedup phase")
}
if isDedupPhase(withParams(0, 3)) {
t.Error("same node but different disk must NOT be a dedup phase")
}
}

View File

@@ -0,0 +1,687 @@
package ec_balance
import (
"context"
"fmt"
"math"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/seaweedfs/seaweedfs/weed/util"
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
const (
ecBalanceMinImbalanceThreshold = 0.05
ecBalanceMaxImbalanceThreshold = 0.5
ecBalanceMinServerCount = 2
)
func init() {
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
JobType: "ec_balance",
Category: pluginworker.CategoryDefault,
Aliases: []string{"ec-balance", "ec.balance", "ec_shard_balance"},
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
return NewECBalanceHandler(opts.GrpcDialOption), nil
},
})
}
type ecBalanceWorkerConfig struct {
TaskConfig *Config
}
// ECBalanceHandler is the plugin job handler for EC shard balancing.
type ECBalanceHandler struct {
grpcDialOption grpc.DialOption
}
func NewECBalanceHandler(grpcDialOption grpc.DialOption) *ECBalanceHandler {
return &ECBalanceHandler{grpcDialOption: grpcDialOption}
}
func (h *ECBalanceHandler) Capability() *plugin_pb.JobTypeCapability {
return &plugin_pb.JobTypeCapability{
JobType: "ec_balance",
CanDetect: true,
CanExecute: true,
MaxDetectionConcurrency: 1,
MaxExecutionConcurrency: 3,
DisplayName: "EC Shard Balance",
Description: "Balance EC shard distribution across racks and nodes",
Weight: 60,
}
}
func (h *ECBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
return &plugin_pb.JobTypeDescriptor{
JobType: "ec_balance",
DisplayName: "EC Shard Balance",
Description: "Detect and execute EC shard rebalancing across the cluster",
Icon: "fas fa-balance-scale-left",
DescriptorVersion: 1,
AdminConfigForm: &plugin_pb.ConfigForm{
FormId: "ec-balance-admin",
Title: "EC Shard Balance Admin Config",
Description: "Admin-side controls for EC shard balance detection scope.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "scope",
Title: "Scope",
Description: "Optional filters applied before EC shard balance detection.",
Fields: []*plugin_pb.ConfigField{
{
Name: "collection_filter",
Label: "Collection Filter",
Description: "Only balance EC shards in matching collections. Comma-separated list of names, wildcards, or regex patterns.",
Placeholder: "all collections",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "data_center_filter",
Label: "Data Center Filter",
Description: "Only balance within matching data centers (wildcard supported).",
Placeholder: "all data centers",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "disk_type",
Label: "Disk Type",
Description: "Only balance EC shards on this disk type (hdd, ssd, or empty for all).",
Placeholder: "all disk types",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "shard_replica_placement",
Label: "Shard Replica Placement",
Description: "EC shard replica placement constraint (e.g. 020); empty uses the master default replication (even spread only when that default is empty or zero). Mirrors the ec.balance -shardReplicaPlacement flag.",
Placeholder: "master default",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
},
},
},
DefaultValues: map[string]*plugin_pb.ConfigValue{
"collection_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
"data_center_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
"disk_type": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
"shard_replica_placement": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
},
},
WorkerConfigForm: &plugin_pb.ConfigForm{
FormId: "ec-balance-worker",
Title: "EC Shard Balance Worker Config",
Description: "Worker-side detection thresholds and execution controls.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "thresholds",
Title: "Detection Thresholds",
Description: "Controls for when EC shard balance jobs should be proposed.",
Fields: []*plugin_pb.ConfigField{
{
Name: "imbalance_threshold",
Label: "Imbalance Threshold",
Description: "Minimum shard count imbalance ratio to trigger balancing (0.0-1.0).",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_DOUBLE,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
Required: true,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: ecBalanceMinImbalanceThreshold}},
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: ecBalanceMaxImbalanceThreshold}},
},
{
Name: "min_server_count",
Label: "Minimum Server Count",
Description: "Minimum servers required for EC shard balancing.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
Required: true,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: ecBalanceMinServerCount}},
},
{
Name: "io_byte_per_second",
Label: "Shard Copy IO Limit (bytes/sec)",
Description: "Limit each EC shard copy's rate in bytes per second. 0 falls back to each volume server's own maintenance rate.",
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}},
},
{
Name: "preferred_tags",
Label: "Preferred Tags",
Description: "Comma-separated disk tags to prioritize for shard placement, ordered by preference.",
Placeholder: "fast,ssd",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
},
},
},
DefaultValues: map[string]*plugin_pb.ConfigValue{
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.2}},
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3}},
"io_byte_per_second": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
"preferred_tags": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
},
},
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
Enabled: true,
DetectionIntervalMinutes: 30,
DetectionTimeoutSeconds: 300,
MaxJobsPerDetection: 500,
GlobalExecutionConcurrency: 16,
PerWorkerExecutionConcurrency: 4,
RetryLimit: 1,
RetryBackoffSeconds: 30,
JobTypeMaxRuntimeSeconds: 1800,
ExecutionTimeoutSeconds: 1800,
},
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.2}},
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3}},
"io_byte_per_second": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
"preferred_tags": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
},
}
}
func (h *ECBalanceHandler) 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 != "ec_balance" {
return fmt.Errorf("job type %q is not handled by ec_balance worker", request.JobType)
}
workerConfig := deriveECBalanceWorkerConfig(request.GetWorkerConfigValues())
// Apply admin-side scope filters
collectionFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "collection_filter", ""))
if collectionFilter != "" {
workerConfig.TaskConfig.CollectionFilter = collectionFilter
}
dcFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "data_center_filter", ""))
if dcFilter != "" {
workerConfig.TaskConfig.DataCenterFilter = dcFilter
}
diskType := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "disk_type", ""))
if diskType != "" {
workerConfig.TaskConfig.DiskType = diskType
}
replicaPlacement := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "shard_replica_placement", ""))
if replicaPlacement != "" {
workerConfig.TaskConfig.ReplicaPlacement = replicaPlacement
}
masters := make([]string, 0)
if request.ClusterContext != nil {
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
}
metrics, activeTopology, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
if err != nil {
return err
}
clusterInfo := &workertypes.ClusterInfo{
ActiveTopology: activeTopology,
DefaultReplicaPlacement: pluginworker.FetchDefaultReplicaPlacement(ctx, masters, h.grpcDialOption),
}
maxResults := int(request.MaxResults)
if maxResults < 0 {
maxResults = 0
}
results, hasMore, err := Detection(ctx, metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
if err != nil {
return err
}
if traceErr := emitECBalanceDecisionTrace(sender, workerConfig.TaskConfig, results, maxResults, hasMore); traceErr != nil {
glog.Warningf("Plugin worker failed to emit ec_balance detection trace: %v", traceErr)
}
proposals := make([]*plugin_pb.JobProposal, 0, len(results))
for _, result := range results {
proposal, proposalErr := buildECBalanceProposal(result)
if proposalErr != nil {
glog.Warningf("Plugin worker skip invalid ec_balance proposal: %v", proposalErr)
continue
}
proposals = append(proposals, proposal)
}
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
JobType: "ec_balance",
Proposals: proposals,
HasMore: hasMore,
}); err != nil {
return err
}
return sender.SendComplete(&plugin_pb.DetectionComplete{
JobType: "ec_balance",
Success: true,
TotalProposals: int32(len(proposals)),
})
}
func (h *ECBalanceHandler) 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 != "ec_balance" {
return fmt.Errorf("job type %q is not handled by ec_balance worker", request.Job.JobType)
}
params, err := decodeECBalanceTaskParams(request.Job)
if err != nil {
return err
}
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
return fmt.Errorf("ec balance source node is required")
}
if len(params.Targets) == 0 || strings.TrimSpace(params.Targets[0].Node) == "" {
return fmt.Errorf("ec balance target node is required")
}
task := NewECBalanceTask(
request.Job.JobId,
params.VolumeId,
params.Collection,
h.grpcDialOption,
)
execCtx, execCancel := context.WithCancel(ctx)
defer execCancel()
task.SetProgressCallback(func(progress float64, stage string) {
message := fmt.Sprintf("ec balance progress %.0f%%", progress)
if strings.TrimSpace(stage) != "" {
message = stage
}
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
State: plugin_pb.JobState_JOB_STATE_RUNNING,
ProgressPercent: progress,
Stage: stage,
Message: message,
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity(stage, message),
},
}); err != nil {
glog.Warningf("EC balance job %s (%s): failed to send progress (%.0f%%, stage=%q): %v, cancelling execution",
request.Job.JobId, request.Job.JobType, progress, stage, err)
execCancel()
}
})
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
ProgressPercent: 0,
Stage: "assigned",
Message: "ec balance job accepted",
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("assigned", "ec balance job accepted"),
},
}); err != nil {
return err
}
if err := task.Execute(execCtx, params); err != nil {
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
State: plugin_pb.JobState_JOB_STATE_FAILED,
ProgressPercent: 100,
Stage: "failed",
Message: err.Error(),
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("failed", err.Error()),
},
})
return err
}
sourceNode := params.Sources[0].Node
targetNode := params.Targets[0].Node
resultSummary := fmt.Sprintf("EC shard balance completed: volume %d shards moved from %s to %s",
params.VolumeId, sourceNode, targetNode)
return sender.SendCompleted(&plugin_pb.JobCompleted{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
Success: true,
Result: &plugin_pb.JobResult{
Summary: resultSummary,
OutputValues: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(params.VolumeId)},
},
"source_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
},
"target_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: targetNode},
},
},
},
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("completed", resultSummary),
},
})
}
func (h *ECBalanceHandler) collectVolumeMetrics(
ctx context.Context,
masterAddresses []string,
collectionFilter string,
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
metrics, activeTopology, _, err := pluginworker.CollectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
return metrics, activeTopology, err
}
func deriveECBalanceWorkerConfig(values map[string]*plugin_pb.ConfigValue) *ecBalanceWorkerConfig {
taskConfig := NewDefaultConfig()
imbalanceThreshold := pluginworker.ReadDoubleConfig(values, "imbalance_threshold", taskConfig.ImbalanceThreshold)
if imbalanceThreshold < ecBalanceMinImbalanceThreshold {
imbalanceThreshold = ecBalanceMinImbalanceThreshold
}
if imbalanceThreshold > ecBalanceMaxImbalanceThreshold {
imbalanceThreshold = ecBalanceMaxImbalanceThreshold
}
taskConfig.ImbalanceThreshold = imbalanceThreshold
minServerCount := pluginworker.ReadIntConfig(values, "min_server_count", taskConfig.MinServerCount)
if minServerCount < ecBalanceMinServerCount {
minServerCount = ecBalanceMinServerCount
}
taskConfig.MinServerCount = minServerCount
ioBytePerSecond := pluginworker.ReadInt64Config(values, "io_byte_per_second", taskConfig.IoBytePerSecond)
if ioBytePerSecond < 0 {
ioBytePerSecond = 0
}
taskConfig.IoBytePerSecond = ioBytePerSecond
taskConfig.PreferredTags = util.NormalizeTagList(pluginworker.ReadStringListConfig(values, "preferred_tags"))
return &ecBalanceWorkerConfig{
TaskConfig: taskConfig,
}
}
func buildECBalanceProposal(result *workertypes.TaskDetectionResult) (*plugin_pb.JobProposal, error) {
if result == nil {
return nil, fmt.Errorf("task detection result is nil")
}
if result.TypedParams == nil {
return nil, fmt.Errorf("missing typed params for volume %d", result.VolumeID)
}
paramsPayload, err := proto.Marshal(result.TypedParams)
if err != nil {
return nil, fmt.Errorf("marshal task params: %w", err)
}
proposalID := strings.TrimSpace(result.TaskID)
if proposalID == "" {
proposalID = fmt.Sprintf("ec-balance-%d-%d", result.VolumeID, time.Now().UnixNano())
}
// Dedupe per (collection, volume), NOT per shard. A volume's EC shards can be
// spread across several disks on one server, and concurrent moves of the same
// volume race on its shared .ecx/.ecj/.vif sidecar files. Keying by volume
// makes the scheduler run only one of a volume's moves at a time — both within
// a detection run and against in-flight jobs — and because the planner emits a
// volume's moves in phase order (dedup, cross-rack, within-rack, global), the
// phases then execute in order across detection cycles. This mirrors the shell,
// which serializes a volume's moves and waits between phases.
dedupeKey := fmt.Sprintf("ec_balance:%d", result.VolumeID)
if result.Collection != "" {
dedupeKey += ":" + result.Collection
}
sourceNode := ""
targetNode := ""
if len(result.TypedParams.Sources) > 0 {
sourceNode = strings.TrimSpace(result.TypedParams.Sources[0].Node)
}
if len(result.TypedParams.Targets) > 0 {
targetNode = strings.TrimSpace(result.TypedParams.Targets[0].Node)
}
summary := fmt.Sprintf("Balance EC shard of volume %d", result.VolumeID)
if sourceNode != "" && targetNode != "" {
summary = fmt.Sprintf("Move EC shard of volume %d: %s → %s", result.VolumeID, sourceNode, targetNode)
}
// EC shard moves only relocate one shard (1/14 of the volume). Budget
// 5 min/GB of full volume size, which conservatively covers the shard
// transfer plus mount/registration overhead.
volumeSizeGB := int64(result.TypedParams.VolumeSize/1024/1024/1024) + 1
estimatedRuntimeSeconds := volumeSizeGB * 5 * 60
return &plugin_pb.JobProposal{
ProposalId: proposalID,
DedupeKey: dedupeKey,
JobType: "ec_balance",
Priority: pluginworker.MapTaskPriority(result.Priority),
Summary: summary,
Detail: strings.TrimSpace(result.Reason),
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {
Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: paramsPayload},
},
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(result.VolumeID)},
},
"source_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
},
"target_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: targetNode},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Collection},
},
"estimated_runtime_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: estimatedRuntimeSeconds},
},
},
Labels: map[string]string{
"task_type": "ec_balance",
"volume_id": fmt.Sprintf("%d", result.VolumeID),
"collection": result.Collection,
"source_node": sourceNode,
"target_node": targetNode,
},
}, nil
}
func decodeECBalanceTaskParams(job *plugin_pb.JobSpec) (*worker_pb.TaskParams, error) {
if job == nil {
return nil, fmt.Errorf("job spec is nil")
}
// Try protobuf-encoded params first (preferred path)
if payload := pluginworker.ReadBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
params := &worker_pb.TaskParams{}
if err := proto.Unmarshal(payload, params); err != nil {
return nil, fmt.Errorf("decodeECBalanceTaskParams: unmarshal task_params_pb: %w", err)
}
if params.TaskId == "" {
params.TaskId = job.JobId
}
// Validate execution-critical fields in the deserialized TaskParams
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Sources[0].Node")
}
if len(params.Sources[0].ShardIds) == 0 {
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Sources[0].ShardIds")
}
if len(params.Targets) == 0 || strings.TrimSpace(params.Targets[0].Node) == "" {
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Targets[0].Node")
}
if len(params.Targets[0].ShardIds) == 0 {
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Targets[0].ShardIds")
}
return params, nil
}
// Legacy fallback: construct TaskParams from individual scalar parameters.
// All execution-critical fields are required.
volumeID := pluginworker.ReadInt64Config(job.Parameters, "volume_id", 0)
sourceNode := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "source_server", ""))
targetNode := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "target_server", ""))
collection := pluginworker.ReadStringConfig(job.Parameters, "collection", "")
if volumeID <= 0 || volumeID > math.MaxUint32 {
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid or missing volume_id: %d", volumeID)
}
if sourceNode == "" {
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing source_server")
}
if targetNode == "" {
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing target_server")
}
shardIDVal, hasShardID := job.Parameters["shard_id"]
if !hasShardID || shardIDVal == nil {
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing shard_id (required for EcBalanceTaskParams)")
}
shardID := pluginworker.ReadInt64Config(job.Parameters, "shard_id", -1)
if shardID < 0 || shardID > math.MaxUint32 {
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid shard_id: %d", shardID)
}
sourceDiskID := pluginworker.ReadInt64Config(job.Parameters, "source_disk_id", 0)
if sourceDiskID < 0 || sourceDiskID > math.MaxUint32 {
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid source_disk_id: %d", sourceDiskID)
}
targetDiskID := pluginworker.ReadInt64Config(job.Parameters, "target_disk_id", 0)
if targetDiskID < 0 || targetDiskID > math.MaxUint32 {
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid target_disk_id: %d", targetDiskID)
}
ioBytePerSecond := pluginworker.ReadInt64Config(job.Parameters, "io_byte_per_second", 0)
return &worker_pb.TaskParams{
TaskId: job.JobId,
VolumeId: uint32(volumeID),
Collection: collection,
Sources: []*worker_pb.TaskSource{{
Node: sourceNode,
DiskId: uint32(sourceDiskID),
ShardIds: []uint32{uint32(shardID)},
}},
Targets: []*worker_pb.TaskTarget{{
Node: targetNode,
DiskId: uint32(targetDiskID),
ShardIds: []uint32{uint32(shardID)},
}},
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
TimeoutSeconds: 600,
IoBytePerSecond: ioBytePerSecond,
},
},
}, nil
}
func emitECBalanceDecisionTrace(
sender pluginworker.DetectionSender,
taskConfig *Config,
results []*workertypes.TaskDetectionResult,
maxResults int,
hasMore bool,
) error {
if sender == nil || taskConfig == nil {
return nil
}
// Count moves by phase
phaseCounts := make(map[string]int)
for _, result := range results {
if result.Reason != "" {
// Extract phase from reason string
for _, phase := range []string{"dedup", "cross_rack", "within_rack", "global"} {
if strings.Contains(result.Reason, phase) {
phaseCounts[phase]++
break
}
}
}
}
summarySuffix := ""
if hasMore {
summarySuffix = fmt.Sprintf(" (max_results=%d reached)", maxResults)
}
summaryMessage := fmt.Sprintf(
"EC balance detection: %d moves proposed%s (dedup=%d, cross_rack=%d, within_rack=%d, global=%d)",
len(results),
summarySuffix,
phaseCounts["dedup"],
phaseCounts["cross_rack"],
phaseCounts["within_rack"],
phaseCounts["global"],
)
return sender.SendActivity(pluginworker.BuildDetectorActivity("decision_summary", summaryMessage, map[string]*plugin_pb.ConfigValue{
"total_moves": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(results))},
},
"has_more": {
Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: hasMore},
},
"dedup_moves": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["dedup"])},
},
"cross_rack_moves": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["cross_rack"])},
},
"within_rack_moves": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["within_rack"])},
},
"global_moves": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["global"])},
},
"imbalance_threshold": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: taskConfig.ImbalanceThreshold},
},
"min_server_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.MinServerCount)},
},
}))
}

View File

@@ -0,0 +1,327 @@
package ec_balance
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
"google.golang.org/protobuf/proto"
)
func TestDeriveECBalanceWorkerConfig(t *testing.T) {
tests := []struct {
name string
values map[string]*plugin_pb.ConfigValue
check func(t *testing.T, config *ecBalanceWorkerConfig)
}{
{
name: "nil values uses defaults",
values: nil,
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
if config.TaskConfig.ImbalanceThreshold != 0.2 {
t.Errorf("expected default threshold 0.2, got %f", config.TaskConfig.ImbalanceThreshold)
}
if config.TaskConfig.MinServerCount != 3 {
t.Errorf("expected default min_server_count 3, got %d", config.TaskConfig.MinServerCount)
}
},
},
{
name: "custom threshold",
values: map[string]*plugin_pb.ConfigValue{
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.3}},
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 5}},
},
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
if config.TaskConfig.ImbalanceThreshold != 0.3 {
t.Errorf("expected threshold 0.3, got %f", config.TaskConfig.ImbalanceThreshold)
}
if config.TaskConfig.MinServerCount != 5 {
t.Errorf("expected min_server_count 5, got %d", config.TaskConfig.MinServerCount)
}
},
},
{
name: "threshold clamped to min",
values: map[string]*plugin_pb.ConfigValue{
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.01}},
},
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
if config.TaskConfig.ImbalanceThreshold != 0.05 {
t.Errorf("expected clamped threshold 0.05, got %f", config.TaskConfig.ImbalanceThreshold)
}
},
},
{
name: "threshold clamped to max",
values: map[string]*plugin_pb.ConfigValue{
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.9}},
},
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
if config.TaskConfig.ImbalanceThreshold != 0.5 {
t.Errorf("expected clamped threshold 0.5, got %f", config.TaskConfig.ImbalanceThreshold)
}
},
},
{
name: "min_server_count clamped to 2",
values: map[string]*plugin_pb.ConfigValue{
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
},
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
if config.TaskConfig.MinServerCount != 2 {
t.Errorf("expected min_server_count 2, got %d", config.TaskConfig.MinServerCount)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := deriveECBalanceWorkerConfig(tt.values)
tt.check(t, config)
})
}
}
func TestBuildECBalanceProposal(t *testing.T) {
result := &workertypes.TaskDetectionResult{
TaskID: "test-task-123",
TaskType: workertypes.TaskTypeECBalance,
VolumeID: 42,
Collection: "test-col",
Priority: workertypes.TaskPriorityMedium,
Reason: "cross_rack balance",
TypedParams: &worker_pb.TaskParams{
VolumeId: 42,
Collection: "test-col",
Sources: []*worker_pb.TaskSource{{
Node: "source:8080",
ShardIds: []uint32{5},
}},
Targets: []*worker_pb.TaskTarget{{
Node: "target:8080",
ShardIds: []uint32{5},
}},
},
}
proposal, err := buildECBalanceProposal(result)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if proposal.JobType != "ec_balance" {
t.Errorf("expected job type ec_balance, got %s", proposal.JobType)
}
if proposal.ProposalId != "test-task-123" {
t.Errorf("expected proposal ID test-task-123, got %s", proposal.ProposalId)
}
// Dedupe is per (volume, collection) so a volume's shard moves serialize and
// run in phase order, rather than per shard which allowed concurrent same-volume
// moves that race on the volume's .ecx/.ecj/.vif sidecars.
if proposal.DedupeKey != "ec_balance:42:test-col" {
t.Errorf("expected dedupe key ec_balance:42:test-col, got %s", proposal.DedupeKey)
}
if proposal.Labels["source_node"] != "source:8080" {
t.Errorf("expected source_node label source:8080, got %s", proposal.Labels["source_node"])
}
if proposal.Labels["target_node"] != "target:8080" {
t.Errorf("expected target_node label target:8080, got %s", proposal.Labels["target_node"])
}
}
func TestBuildECBalanceProposalNilResult(t *testing.T) {
_, err := buildECBalanceProposal(nil)
if err == nil {
t.Fatal("expected error for nil result")
}
}
func TestDecodeECBalanceTaskParamsFromProtobuf(t *testing.T) {
originalParams := &worker_pb.TaskParams{
TaskId: "test-id",
VolumeId: 100,
Collection: "test-col",
Sources: []*worker_pb.TaskSource{{
Node: "source:8080",
ShardIds: []uint32{3},
}},
Targets: []*worker_pb.TaskTarget{{
Node: "target:8080",
ShardIds: []uint32{3},
}},
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
DiskType: "hdd",
TimeoutSeconds: 300,
},
},
}
payload, err := proto.Marshal(originalParams)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
job := &plugin_pb.JobSpec{
JobId: "job-1",
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
},
}
decoded, err := decodeECBalanceTaskParams(job)
if err != nil {
t.Fatalf("failed to decode: %v", err)
}
if decoded.VolumeId != 100 {
t.Errorf("expected volume_id 100, got %d", decoded.VolumeId)
}
if decoded.Collection != "test-col" {
t.Errorf("expected collection test-col, got %s", decoded.Collection)
}
if len(decoded.Sources) != 1 || decoded.Sources[0].Node != "source:8080" {
t.Error("source mismatch")
}
if len(decoded.Targets) != 1 || decoded.Targets[0].Node != "target:8080" {
t.Error("target mismatch")
}
ecParams := decoded.GetEcBalanceParams()
if ecParams == nil {
t.Fatal("expected ec_balance_params")
}
if ecParams.DiskType != "hdd" {
t.Errorf("expected disk_type hdd, got %s", ecParams.DiskType)
}
}
func TestDecodeECBalanceTaskParamsFallback(t *testing.T) {
job := &plugin_pb.JobSpec{
JobId: "job-2",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 200}},
"source_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "src:8080"}},
"target_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "dst:8080"}},
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "fallback-col"}},
"shard_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7}},
"source_disk_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
"target_disk_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
},
}
decoded, err := decodeECBalanceTaskParams(job)
if err != nil {
t.Fatalf("failed to decode fallback: %v", err)
}
if decoded.VolumeId != 200 {
t.Errorf("expected volume_id 200, got %d", decoded.VolumeId)
}
if len(decoded.Sources) != 1 || decoded.Sources[0].Node != "src:8080" {
t.Error("source mismatch")
}
if decoded.Sources[0].ShardIds[0] != 7 {
t.Errorf("expected shard_id 7, got %d", decoded.Sources[0].ShardIds[0])
}
if decoded.Sources[0].DiskId != 1 {
t.Errorf("expected source disk_id 1, got %d", decoded.Sources[0].DiskId)
}
if decoded.Targets[0].DiskId != 2 {
t.Errorf("expected target disk_id 2, got %d", decoded.Targets[0].DiskId)
}
}
func TestDecodeECBalanceTaskParamsProtobufValidation(t *testing.T) {
// Protobuf payload with missing ShardIds should fail validation
badParams := &worker_pb.TaskParams{
TaskId: "test-id",
VolumeId: 100,
Sources: []*worker_pb.TaskSource{{Node: "source:8080"}}, // no ShardIds
Targets: []*worker_pb.TaskTarget{{Node: "target:8080", ShardIds: []uint32{3}}},
}
payload, _ := proto.Marshal(badParams)
job := &plugin_pb.JobSpec{
JobId: "job-validate",
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
},
}
_, err := decodeECBalanceTaskParams(job)
if err == nil {
t.Fatal("expected error for missing Sources[0].ShardIds in protobuf")
}
}
func TestDecodeECBalanceTaskParamsMissingShardID(t *testing.T) {
job := &plugin_pb.JobSpec{
JobId: "job-3",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 300}},
"source_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "src:8080"}},
"target_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "dst:8080"}},
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "test-col"}},
// shard_id deliberately missing
},
}
_, err := decodeECBalanceTaskParams(job)
if err == nil {
t.Fatal("expected error for missing shard_id")
}
}
func TestECBalanceHandlerCapability(t *testing.T) {
handler := NewECBalanceHandler(nil)
cap := handler.Capability()
if cap.JobType != "ec_balance" {
t.Errorf("expected job type ec_balance, got %s", cap.JobType)
}
if !cap.CanDetect {
t.Error("expected CanDetect=true")
}
if !cap.CanExecute {
t.Error("expected CanExecute=true")
}
if cap.Weight != 60 {
t.Errorf("expected weight 60, got %d", cap.Weight)
}
}
func TestECBalanceConfigRoundTrip(t *testing.T) {
config := NewDefaultConfig()
config.ImbalanceThreshold = 0.3
config.MinServerCount = 5
config.CollectionFilter = "my_col"
config.DiskType = "ssd"
config.PreferredTags = []string{"fast", "ssd"}
policy := config.ToTaskPolicy()
if policy == nil {
t.Fatal("expected non-nil policy")
}
config2 := NewDefaultConfig()
if err := config2.FromTaskPolicy(policy); err != nil {
t.Fatalf("failed to load from policy: %v", err)
}
if config2.ImbalanceThreshold != 0.3 {
t.Errorf("expected threshold 0.3, got %f", config2.ImbalanceThreshold)
}
if config2.MinServerCount != 5 {
t.Errorf("expected min_server_count 5, got %d", config2.MinServerCount)
}
if config2.CollectionFilter != "my_col" {
t.Errorf("expected collection_filter my_col, got %s", config2.CollectionFilter)
}
if config2.DiskType != "ssd" {
t.Errorf("expected disk_type ssd, got %s", config2.DiskType)
}
if len(config2.PreferredTags) != 2 || config2.PreferredTags[0] != "fast" {
t.Errorf("expected preferred_tags [fast ssd], got %v", config2.PreferredTags)
}
}

View File

@@ -0,0 +1,89 @@
package ec_balance
import (
"context"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Global variable to hold the task definition for configuration updates
var globalTaskDef *base.TaskDefinition
// Auto-register this task when the package is imported
func init() {
RegisterECBalanceTask()
// Register config updater
tasks.AutoRegisterConfigUpdater(types.TaskTypeECBalance, UpdateConfigFromPersistence)
}
// RegisterECBalanceTask registers the EC balance task with the task architecture
func RegisterECBalanceTask() {
cfg := NewDefaultConfig()
// Create shared gRPC dial option using TLS configuration
dialOpt := security.LoadClientTLS(util.GetViper(), "grpc.worker")
// Create complete task definition
taskDef := &base.TaskDefinition{
Type: types.TaskTypeECBalance,
Name: "ec_balance",
DisplayName: "EC Shard Balance",
Description: "Balances EC shard distribution across racks and servers",
Icon: "fas fa-balance-scale-left text-info",
Capabilities: []string{"ec_balance", "erasure_coding"},
Config: cfg,
ConfigSpec: GetConfigSpec(),
CreateTask: func(params *worker_pb.TaskParams) (types.Task, error) {
if params == nil {
return nil, fmt.Errorf("task parameters are required")
}
return NewECBalanceTask(
fmt.Sprintf("ec_balance-%d", params.VolumeId),
params.VolumeId,
params.Collection,
dialOpt,
), nil
},
DetectionFunc: func(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig) ([]*types.TaskDetectionResult, error) {
results, _, err := Detection(context.Background(), metrics, clusterInfo, config, 0)
return results, err
},
ScanInterval: 1 * time.Hour,
SchedulingFunc: Scheduling,
MaxConcurrent: 1,
RepeatInterval: 4 * time.Hour,
}
// Store task definition globally for configuration updates
globalTaskDef = taskDef
// Register everything with a single function call
base.RegisterTask(taskDef)
}
// UpdateConfigFromPersistence updates the EC balance configuration from persistence
func UpdateConfigFromPersistence(configPersistence interface{}) error {
if globalTaskDef == nil {
return fmt.Errorf("ec balance task not registered")
}
newConfig := LoadConfigFromPersistence(configPersistence)
if newConfig == nil {
return fmt.Errorf("failed to load configuration from persistence")
}
globalTaskDef.Config = newConfig
glog.V(1).Infof("Updated EC balance task configuration from persistence")
return nil
}

View File

@@ -0,0 +1,40 @@
package ec_balance
import (
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Scheduling implements the scheduling logic for EC balance tasks
func Scheduling(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData, config base.TaskConfig) bool {
ecbConfig := config.(*Config)
// Check if we have available workers
if len(availableWorkers) == 0 {
return false
}
// Count running EC balance tasks
runningCount := 0
for _, runningTask := range runningTasks {
if runningTask.Type == types.TaskTypeECBalance {
runningCount++
}
}
// Check concurrency limit
if runningCount >= ecbConfig.MaxConcurrent {
return false
}
// Check if any worker can handle EC balance tasks
for _, worker := range availableWorkers {
for _, capability := range worker.Capabilities {
if capability == types.TaskTypeECBalance {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,255 @@
package erasure_coding
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/config"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
)
// Config extends BaseConfig with erasure coding specific settings
type Config struct {
base.BaseConfig
QuietForSeconds int `json:"quiet_for_seconds"`
FullnessRatio float64 `json:"fullness_ratio"`
CollectionFilter string `json:"collection_filter"`
MinSizeMB int `json:"min_size_mb"`
PreferredTags []string `json:"preferred_tags"`
ReplicaPlacement string `json:"replica_placement"` // e.g. "020"; empty falls back to the master default replication
}
// NewDefaultConfig creates a new default erasure coding configuration
func NewDefaultConfig() *Config {
return &Config{
BaseConfig: base.BaseConfig{
Enabled: true,
ScanIntervalSeconds: 60 * 60, // 1 hour
MaxConcurrent: 1,
},
QuietForSeconds: 3600, // 1 hour, matching the shell ec.encode -quietFor default
FullnessRatio: 0.95, // 95%, matching the shell ec.encode -fullPercent default
CollectionFilter: "",
MinSizeMB: 30, // 30MB (more reasonable than 100MB)
PreferredTags: nil,
}
}
// GetConfigSpec returns the configuration schema for erasure coding tasks
func GetConfigSpec() base.ConfigSpec {
return base.ConfigSpec{
Fields: []*config.Field{
{
Name: "enabled",
JSONName: "enabled",
Type: config.FieldTypeBool,
DefaultValue: true,
Required: false,
DisplayName: "Enable Erasure Coding Tasks",
Description: "Whether erasure coding tasks should be automatically created",
HelpText: "Toggle this to enable or disable automatic erasure coding task generation",
InputType: "checkbox",
CSSClasses: "form-check-input",
},
{
Name: "scan_interval_seconds",
JSONName: "scan_interval_seconds",
Type: config.FieldTypeInterval,
DefaultValue: 60 * 60,
MinValue: 10 * 60,
MaxValue: 24 * 60 * 60,
Required: true,
DisplayName: "Scan Interval",
Description: "How often to scan for volumes needing erasure coding",
HelpText: "The system will check for volumes that need erasure coding at this interval",
Placeholder: "1",
Unit: config.UnitHours,
InputType: "interval",
CSSClasses: "form-control",
},
{
Name: "max_concurrent",
JSONName: "max_concurrent",
Type: config.FieldTypeInt,
DefaultValue: 1,
MinValue: 1,
MaxValue: 5,
Required: true,
DisplayName: "Max Concurrent Tasks",
Description: "Maximum number of erasure coding tasks that can run simultaneously",
HelpText: "Limits the number of erasure coding operations running at the same time",
Placeholder: "1 (default)",
Unit: config.UnitCount,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "quiet_for_seconds",
JSONName: "quiet_for_seconds",
Type: config.FieldTypeInterval,
DefaultValue: 3600,
MinValue: 1,
MaxValue: 3600,
Required: true,
DisplayName: "Quiet Period",
Description: "Minimum time volume must be quiet before erasure coding",
HelpText: "Volume must not be modified for this duration before erasure coding",
Placeholder: "60",
Unit: config.UnitMinutes,
InputType: "interval",
CSSClasses: "form-control",
},
{
Name: "fullness_ratio",
JSONName: "fullness_ratio",
Type: config.FieldTypeFloat,
DefaultValue: 0.95,
MinValue: 0.0001,
MaxValue: 1.0,
Required: true,
DisplayName: "Fullness Ratio",
Description: "Minimum fullness ratio to trigger erasure coding",
HelpText: "Only volumes with this fullness ratio or higher will be erasure coded",
Placeholder: "0.80 (80%)",
Unit: config.UnitNone,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "collection_filter",
JSONName: "collection_filter",
Type: config.FieldTypeString,
DefaultValue: "",
Required: false,
DisplayName: "Collection Filter",
Description: "Only process volumes from specific collections (comma-separated names, wildcards, or regex patterns)",
HelpText: "Leave empty to process all collections, or list the collections to process",
Placeholder: "pictures,videos",
InputType: "text",
CSSClasses: "form-control",
},
{
Name: "min_size_mb",
JSONName: "min_size_mb",
Type: config.FieldTypeInt,
DefaultValue: 30,
MinValue: 1,
MaxValue: 1000,
Required: true,
DisplayName: "Minimum Size (MB)",
Description: "Minimum volume size to consider for erasure coding",
HelpText: "Only volumes larger than this size will be considered for erasure coding",
Placeholder: "30",
Unit: config.UnitNone,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "preferred_tags",
JSONName: "preferred_tags",
Type: config.FieldTypeString,
DefaultValue: "",
Required: false,
DisplayName: "Preferred Disk Tags",
Description: "Comma-separated disk tags to prioritize for EC shard placement",
HelpText: "EC shards will be placed on disks with these tags first, then fall back to other disks if needed",
Placeholder: "fast,ssd",
InputType: "text",
CSSClasses: "form-control",
},
{
Name: "replica_placement",
JSONName: "replica_placement",
Type: config.FieldTypeString,
DefaultValue: "",
Required: false,
DisplayName: "Replica Placement",
Description: "EC shard replica placement constraint (e.g. 020)",
HelpText: "Leave empty to use the master default replication. When set, the 2nd/3rd digits cap EC shards per rack and per node (best-effort during encode: relaxed rather than failing if the cluster can't satisfy them, then enforced by rebalancing). The 1st (data-center) digit is ignored for EC placement",
Placeholder: "020",
InputType: "text",
CSSClasses: "form-control",
},
},
}
}
// ToTaskPolicy converts configuration to a TaskPolicy protobuf message
func (c *Config) ToTaskPolicy() *worker_pb.TaskPolicy {
// Defensive copy of PreferredTags to prevent external mutation
preferredTagsCopy := append([]string(nil), c.PreferredTags...)
return &worker_pb.TaskPolicy{
Enabled: c.Enabled,
MaxConcurrent: int32(c.MaxConcurrent),
RepeatIntervalSeconds: int32(c.ScanIntervalSeconds),
CheckIntervalSeconds: int32(c.ScanIntervalSeconds),
TaskConfig: &worker_pb.TaskPolicy_ErasureCodingConfig{
ErasureCodingConfig: &worker_pb.ErasureCodingTaskConfig{
FullnessRatio: float64(c.FullnessRatio),
QuietForSeconds: int32(c.QuietForSeconds),
MinVolumeSizeMb: int32(c.MinSizeMB),
CollectionFilter: c.CollectionFilter,
PreferredTags: preferredTagsCopy,
ReplicaPlacement: c.ReplicaPlacement,
},
},
}
}
// FromTaskPolicy loads configuration from a TaskPolicy protobuf message
func (c *Config) FromTaskPolicy(policy *worker_pb.TaskPolicy) error {
if policy == nil {
return fmt.Errorf("policy is nil")
}
// Set general TaskPolicy fields
c.Enabled = policy.Enabled
c.MaxConcurrent = int(policy.MaxConcurrent)
c.ScanIntervalSeconds = int(policy.RepeatIntervalSeconds) // Direct seconds-to-seconds mapping
// Set erasure coding-specific fields from the task config
if ecConfig := policy.GetErasureCodingConfig(); ecConfig != nil {
c.FullnessRatio = float64(ecConfig.FullnessRatio)
c.QuietForSeconds = int(ecConfig.QuietForSeconds)
c.MinSizeMB = int(ecConfig.MinVolumeSizeMb)
c.CollectionFilter = ecConfig.CollectionFilter
c.PreferredTags = append([]string(nil), ecConfig.PreferredTags...)
c.ReplicaPlacement = ecConfig.ReplicaPlacement
}
return nil
}
// LoadConfigFromPersistence loads configuration from the persistence layer if available
func LoadConfigFromPersistence(configPersistence interface{}) *Config {
config := NewDefaultConfig()
// Try to load from persistence if available
if persistence, ok := configPersistence.(interface {
LoadErasureCodingTaskPolicy() (*worker_pb.TaskPolicy, error)
}); ok {
policy, err := persistence.LoadErasureCodingTaskPolicy()
switch {
case err != nil:
glog.Warningf("Could not read the persisted erasure coding configuration, falling back to defaults: %v", err)
case policy == nil:
glog.V(1).Infof("No erasure coding configuration persisted yet, using defaults")
default:
if err := config.FromTaskPolicy(policy); err != nil {
glog.Warningf("Could not apply the persisted erasure coding configuration, falling back to defaults: %v", err)
} else {
glog.V(1).Infof("Loaded erasure coding configuration from persistence")
return config
}
}
} else if configPersistence != nil {
// A store was handed in but does not expose the accessor, so the persisted
// settings are silently ignored - always a wiring bug, never a normal state.
glog.Warningf("%T cannot provide the persisted erasure coding configuration: it has no LoadErasureCodingTaskPolicy() method, "+
"so the compiled-in defaults are used and any saved erasure coding settings are ignored", configPersistence)
}
glog.V(1).Infof("Using default erasure coding configuration")
return config
}

View File

@@ -0,0 +1,772 @@
package erasure_coding
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/ec"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
workerutil "github.com/seaweedfs/seaweedfs/weed/worker/tasks/util"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
const (
minProposalsBeforeEarlyStop = 10
maxConsecutivePlanningFailures = 10
)
// Detection implements the detection logic for erasure coding tasks.
// It respects ctx cancellation and can stop early once maxResults is reached.
func Detection(ctx context.Context, metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig, maxResults int) ([]*types.TaskDetectionResult, bool, error) {
if !config.IsEnabled() {
return nil, false, nil
}
if maxResults < 0 {
maxResults = 0
}
ecConfig := config.(*Config)
var results []*types.TaskDetectionResult
hasMore := false
stoppedEarly := false
now := time.Now()
quietThreshold := time.Duration(ecConfig.QuietForSeconds) * time.Second
minSizeBytes := uint64(ecConfig.MinSizeMB) * 1024 * 1024 // Configurable minimum
debugCount := 0
skippedAlreadyEC := 0
skippedTooSmall := 0
skippedCollectionFilter := 0
skippedQuietTime := 0
skippedFullness := 0
skippedRemote := 0
skippedTooFewNodes := 0
consecutivePlanningFailures := 0
// EC shard replica placement: explicit config wins, else the master default.
var replicaPlacement *super_block.ReplicaPlacement
if clusterInfo != nil {
replicaPlacement = super_block.ResolveReplicaPlacement(ecConfig.ReplicaPlacement, clusterInfo.DefaultReplicaPlacement)
}
// EC placement honors only the rack/node digits; the data-center digit can't
// express a useful per-DC EC shard cap (it maxes at 2). Warn once per cycle so a
// 1xx/2xx setting isn't silently ineffective.
if replicaPlacement != nil && replicaPlacement.DiffDataCenterCount > 0 {
glog.Warningf("EC Detection: replica placement data-center digit (%d) is ignored for EC; only rack/node digits are honored", replicaPlacement.DiffDataCenterCount)
}
allowedCollections, err := wildcard.CompileCollectionMatcher(ecConfig.CollectionFilter)
if err != nil {
return nil, false, err
}
// Cluster node count for the min-node safety gate (mirrors the shell ec.encode
// guard that refuses to encode when nodes < parity shards, so shards cannot be
// spread for fault tolerance).
clusterNodeCount := countTopologyNodes(clusterInfo.ActiveTopology)
// Group metrics by VolumeID to handle replicas and select canonical server
volumeGroups := make(map[uint32][]*types.VolumeHealthMetrics)
for _, metric := range metrics {
if ctx != nil {
if err := ctx.Err(); err != nil {
return results, hasMore, err
}
}
volumeGroups[metric.VolumeID] = append(volumeGroups[metric.VolumeID], metric)
}
groupKeys := make([]uint32, 0, len(volumeGroups))
for volumeID := range volumeGroups {
groupKeys = append(groupKeys, volumeID)
}
sort.Slice(groupKeys, func(i, j int) bool { return groupKeys[i] < groupKeys[j] })
// Build the EC placement snapshot once per detection cycle. planECDestinations
// reserves the shards it assigns directly into it, so volumes planned later in
// this cycle see the reduced capacity. Rebuilding it per volume re-walked the
// whole topology (and ActiveTopology's growing pending-task set) every time,
// which is O(volumes × topology) and times out on large clusters. The node
// address map is precomputed for the same reason (ResolveServerAddress rebuilds
// the full node map on every call).
var ecSnapshot *ecbalancer.Topology
var nodeAddresses map[string]string
if clusterInfo != nil && clusterInfo.ActiveTopology != nil {
ecSnapshot = ecbalancer.FromActiveTopology(clusterInfo.ActiveTopology, erasure_coding.DataShardsCount)
nodeAddresses = buildNodeAddressMap(clusterInfo.ActiveTopology)
}
// Iterate over groups to check criteria and creation tasks
for idx, volumeID := range groupKeys {
if ctx != nil {
if err := ctx.Err(); err != nil {
return results, hasMore, err
}
}
if maxResults > 0 && len(results) >= maxResults {
if idx+1 < len(groupKeys) {
hasMore = true
}
stoppedEarly = true
break
}
groupMetrics := volumeGroups[volumeID]
// Prefer the lowest-server credible replica so a 0-byte stub or a
// leftover EC shard set on a lower server can't become canonical and
// strand the volume in skippedTooSmall / skippedAlreadyEC.
metric := selectCanonicalMetric(groupMetrics)
// Skip if already EC volume
if metric.IsECVolume {
skippedAlreadyEC++
continue
}
// Handle the "stuck source" state from #9448: a previous encode
// succeeded but the post-encode source-delete left a regular replica
// behind, so the master heartbeats BOTH the replica AND its EC shards.
// metric.IsECVolume above is set only for the EC-side metric path, so
// the canonical metric we picked is the regular replica with
// IsECVolume=false. Re-proposing an encode in that state collides with
// the mounted shards on the targets ("ec volume %d is mounted; refusing
// overwrite") and the detector re-queues forever.
//
// We only act when the EC shard set is COMPLETE — fewer than
// totalShards present means the existing recovery branch below
// (around the `existingECShards` block) should keep its chance to
// fold the partial shards into the new task. Counting walks
// EcIndexBits to handle a single info entry carrying multiple shards.
if clusterInfo.ActiveTopology != nil {
shardCount := countExistingEcShardsForVolume(clusterInfo.ActiveTopology, metric.VolumeID, metric.Collection)
totalShards := erasure_coding.DataShardsCount + erasure_coding.ParityShardsCount
if shardCount >= totalShards {
glog.Warningf("EC Detection: Volume %d has all %d EC shards in topology; "+
"source replica on %s is orphaned (#9448).",
metric.VolumeID, totalShards, metric.Server)
if clusterInfo.GrpcDialOption != nil {
deleted, cleanupErr := cleanupOrphanSourceReplicas(ctx, clusterInfo, metric, totalShards)
switch {
case cleanupErr != nil:
// Don't fall through to a re-encode — that would just
// collide with the mounted shards again. Surface the
// failure and wait for the next cycle; the source is
// still safe.
glog.Warningf("EC Detection: failed to auto-clean orphaned source for volume %d: %v", metric.VolumeID, cleanupErr)
case deleted > 0:
glog.Infof("EC Detection: auto-cleaned %d orphaned source replica(s) for volume %d after verifying all %d EC shards present", deleted, metric.VolumeID, totalShards)
default:
glog.V(1).Infof("EC Detection: no orphaned regular replicas found in topology for volume %d (collection %q)", metric.VolumeID, metric.Collection)
}
} else {
glog.Warningf("EC Detection: no gRPC dial option available to auto-clean orphaned source for volume %d; "+
"to clean up by hand, send a targeted VolumeDelete RPC to %s only — DO NOT use the cluster-wide `volume.delete` shell command, which would also delete the EC shards.",
metric.VolumeID, metric.Server)
}
skippedAlreadyEC++
continue
}
}
// Check minimum size requirement
if metric.Size < minSizeBytes {
skippedTooSmall++
continue
}
// Check collection filter if specified
if !allowedCollections.Matches(metric.Collection) {
skippedCollectionFilter++
continue
}
// Skip remote/tiered volumes: encoding them would lose the tiering. The
// shell ec.encode excludes remote volumes for the same reason.
if metric.HasRemoteCopy {
skippedRemote++
continue
}
// Min-node safety gate: don't encode when the cluster has fewer nodes than
// this collection's parity shards — shards could not be spread to tolerate
// failures. Mirrors the shell ec.encode guard (node count < parity shards).
if clusterNodeCount > 0 && clusterNodeCount < erasure_coding.ParityShardsCount {
skippedTooFewNodes++
continue
}
// Check quiet duration and fullness criteria
if metric.Age >= quietThreshold && metric.FullnessRatio >= ecConfig.FullnessRatio {
if ctx != nil {
if err := ctx.Err(); err != nil {
return results, hasMore, err
}
}
glog.Infof("EC Detection: Volume %d meets all criteria, attempting to create task", metric.VolumeID)
// Generate task ID for ActiveTopology integration
taskID := fmt.Sprintf("ec_vol_%d_%d", metric.VolumeID, now.Unix())
result := &types.TaskDetectionResult{
TaskID: taskID, // Link to ActiveTopology pending task
TaskType: types.TaskTypeErasureCoding,
VolumeID: metric.VolumeID,
Server: metric.Server,
Collection: metric.Collection,
Priority: types.TaskPriorityLow, // EC is not urgent
Reason: fmt.Sprintf("Volume meets EC criteria: quiet for %.1fs (>%ds), fullness=%.1f%% (>%.1f%%), size=%.1fMB (>%dMB)",
metric.Age.Seconds(), ecConfig.QuietForSeconds, metric.FullnessRatio*100, ecConfig.FullnessRatio*100,
float64(metric.Size)/(1024*1024), ecConfig.MinSizeMB),
ScheduleAt: now,
}
// Plan EC destinations if ActiveTopology is available
if clusterInfo.ActiveTopology != nil {
// Check if ANY task already exists in ActiveTopology for this volume
if clusterInfo.ActiveTopology.HasAnyTask(metric.VolumeID) {
glog.V(2).Infof("EC Detection: Skipping volume %d, task already exists in ActiveTopology", metric.VolumeID)
continue
}
glog.Infof("EC Detection: ActiveTopology available, planning destinations for volume %d", metric.VolumeID)
dataShards := erasure_coding.DataShardsCount
parityShards := erasure_coding.ParityShardsCount
multiPlan, shardsPerPlan, err := planECDestinations(ecSnapshot, nodeAddresses, metric, ecConfig, replicaPlacement, dataShards, parityShards)
if err != nil {
glog.V(2).Infof("Failed to plan EC destinations for volume %d: %v", metric.VolumeID, err)
consecutivePlanningFailures++
if len(results) >= minProposalsBeforeEarlyStop && consecutivePlanningFailures >= maxConsecutivePlanningFailures {
glog.Warningf("EC Detection: stopping early after %d consecutive placement failures with %d proposals already planned", consecutivePlanningFailures, len(results))
hasMore = true
stoppedEarly = true
break
}
continue // Skip this volume if destination planning fails
}
consecutivePlanningFailures = 0
glog.Infof("EC Detection: Successfully planned %d destinations for volume %d", len(multiPlan.Plans), metric.VolumeID)
// Calculate expected shard size for EC operation
// Each data shard will be approximately volumeSize / dataShards
expectedShardSize := uint64(metric.Size) / uint64(dataShards)
// Add pending EC shard task to ActiveTopology for capacity management
// Extract shard destinations from multiPlan
var shardDestinations []string
var shardDiskIDs []uint32
for _, plan := range multiPlan.Plans {
shardDestinations = append(shardDestinations, plan.TargetNode)
shardDiskIDs = append(shardDiskIDs, plan.TargetDisk)
}
// Find all volume replica locations (server + disk) from topology
glog.Infof("EC Detection: Looking for replica locations for volume %d", metric.VolumeID)
replicaLocations := findVolumeReplicaLocations(clusterInfo.ActiveTopology, metric.VolumeID, metric.Collection)
if len(replicaLocations) == 0 {
glog.Warningf("No replica locations found for volume %d, skipping EC", metric.VolumeID)
continue
}
glog.Infof("EC Detection: Found %d replica locations for volume %d", len(replicaLocations), metric.VolumeID)
// Find existing EC shards from previous failed attempts
existingECShards := findExistingECShards(clusterInfo.ActiveTopology, metric.VolumeID, metric.Collection)
// Combine volume replicas and existing EC shards for cleanup
var sources []topology.TaskSourceSpec
// Add volume replicas (will free volume slots)
for _, replica := range replicaLocations {
sources = append(sources, topology.TaskSourceSpec{
ServerID: replica.ServerID,
DiskID: replica.DiskID,
DataCenter: replica.DataCenter,
Rack: replica.Rack,
CleanupType: topology.CleanupVolumeReplica,
})
}
// Add existing EC shards (will free shard slots)
duplicateCheck := make(map[string]bool)
for _, replica := range replicaLocations {
key := fmt.Sprintf("%s:%d", replica.ServerID, replica.DiskID)
duplicateCheck[key] = true
}
for _, shard := range existingECShards {
key := fmt.Sprintf("%s:%d", shard.ServerID, shard.DiskID)
if !duplicateCheck[key] { // Avoid duplicates if EC shards are on same disk as volume replicas
shardIds := append([]uint32(nil), shard.ShardIds...)
// Free exactly the shards on this disk. Without an explicit
// impact the cleanup falls back to CalculateECShardCleanupImpact,
// which credits TotalShardsCount (14) regardless of ratio.
cleanupImpact := topology.StorageSlotChange{ShardSlots: -int32(len(shardIds))}
sources = append(sources, topology.TaskSourceSpec{
ServerID: shard.ServerID,
DiskID: shard.DiskID,
DataCenter: shard.DataCenter,
Rack: shard.Rack,
CleanupType: topology.CleanupECShards,
ShardIds: shardIds,
StorageImpact: &cleanupImpact,
})
duplicateCheck[key] = true
}
}
glog.V(2).Infof("Found %d volume replicas and %d existing EC shards for volume %d (total %d cleanup sources)",
len(replicaLocations), len(existingECShards), metric.VolumeID, len(sources))
// Convert shard destinations to TaskDestinationSpec. A destination may
// hold several shards (small clusters), so reserve capacity for the
// actual per-disk shard count that Place assigned (shardsPerPlan),
// which is exactly what createECTargets writes.
destinations := make([]topology.TaskDestinationSpec, len(shardDestinations))
for i, dest := range shardDestinations {
shardCount := len(shardsPerPlan[i])
shardImpact := topology.CalculateECShardStorageImpact(int32(shardCount), int64(expectedShardSize))
destSize := int64(expectedShardSize) * int64(shardCount)
destinations[i] = topology.TaskDestinationSpec{
ServerID: dest,
DiskID: shardDiskIDs[i],
StorageImpact: &shardImpact,
EstimatedSize: &destSize,
}
}
// Convert sources before mutating topology
sourcesProto, err := convertTaskSourcesToProtobuf(sources, metric.VolumeID, clusterInfo.ActiveTopology)
if err != nil {
glog.Warningf("Failed to convert sources for EC task on volume %d: %v, skipping", metric.VolumeID, err)
continue
}
err = clusterInfo.ActiveTopology.AddPendingTask(topology.TaskSpec{
TaskID: taskID,
TaskType: topology.TaskTypeErasureCoding,
VolumeID: metric.VolumeID,
VolumeSize: int64(metric.Size),
Sources: sources,
Destinations: destinations,
})
if err != nil {
glog.Warningf("Failed to add pending EC shard task to ActiveTopology for volume %d: %v", metric.VolumeID, err)
continue // Skip this volume if topology task addition fails
}
// Cross-volume in-cycle capacity is tracked by ActiveTopology via the
// pending task above, which the next volume's FromActiveTopology snapshot
// reflects; no separate planner reservation is needed.
glog.V(2).Infof("Added pending EC shard task %s to ActiveTopology for volume %d with %d cleanup sources and %d shard destinations",
taskID, metric.VolumeID, len(sources), len(multiPlan.Plans))
// Create unified sources and targets for EC task
result.TypedParams = &worker_pb.TaskParams{
TaskId: taskID, // Link to ActiveTopology pending task
VolumeId: metric.VolumeID,
Collection: metric.Collection,
VolumeSize: metric.Size, // Store original volume size for tracking changes
// Unified sources - all sources that will be processed/cleaned up
Sources: sourcesProto,
// Unified targets - all EC shard destinations
Targets: createECTargets(multiPlan, shardsPerPlan),
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
ErasureCodingParams: createECTaskParams(dataShards, parityShards, metric.DiskType),
},
}
glog.V(1).Infof("Planned EC destinations for volume %d: %d shards across %d racks, %d DCs",
metric.VolumeID, len(multiPlan.Plans), multiPlan.SuccessfulRack, multiPlan.SuccessfulDCs)
} else {
glog.Warningf("No ActiveTopology available for destination planning in EC detection")
continue // Skip this volume if no topology available
}
glog.Infof("EC Detection: Successfully created EC task for volume %d, adding to results", metric.VolumeID)
results = append(results, result)
} else {
// Count debug reasons
if metric.Age < quietThreshold {
skippedQuietTime++
}
if metric.FullnessRatio < ecConfig.FullnessRatio {
skippedFullness++
}
if debugCount < 5 { // Limit to avoid spam
// Logic moved outside
}
debugCount++
}
}
// Log debug summary if no tasks were created
if len(results) == 0 && len(metrics) > 0 && !stoppedEarly {
totalVolumes := len(metrics)
glog.V(1).Infof("EC detection: No tasks created for %d volumes (skipped: %d already EC, %d too small, %d filtered, %d not quiet, %d not full, %d remote, %d too few nodes)",
totalVolumes, skippedAlreadyEC, skippedTooSmall, skippedCollectionFilter, skippedQuietTime, skippedFullness, skippedRemote, skippedTooFewNodes)
// Show details for first few volumes
for i, metric := range metrics {
if i >= 3 || metric.IsECVolume { // Limit to first 3 non-EC volumes
continue
}
sizeMB := float64(metric.Size) / (1024 * 1024)
glog.V(1).Infof("ERASURE CODING: Volume %d: size=%.1fMB (need ≥%dMB), age=%s (need ≥%s), fullness=%.1f%% (need ≥%.1f%%)",
metric.VolumeID, sizeMB, ecConfig.MinSizeMB, metric.Age.Truncate(time.Minute), quietThreshold.Truncate(time.Minute),
metric.FullnessRatio*100, ecConfig.FullnessRatio*100)
}
}
return results, hasMore, nil
}
// countTopologyNodes counts volume-server nodes in the active topology, used by
// the min-node safety gate.
func countTopologyNodes(at *topology.ActiveTopology) int {
if at == nil {
return 0
}
topo := at.GetTopologyInfo()
if topo == nil {
return 0
}
n := 0
for _, dc := range topo.DataCenterInfos {
for _, rack := range dc.RackInfos {
n += len(rack.DataNodeInfos)
}
}
return n
}
// buildNodeAddressMap resolves every node's server address once per detection
// cycle. ResolveServerAddress rebuilds the full node map on each call, so
// resolving per shard destination is O(destinations × topology); callers build
// this map once and look up addresses from it.
func buildNodeAddressMap(at *topology.ActiveTopology) map[string]string {
if at == nil {
return nil
}
allNodes := at.GetAllNodes()
m := make(map[string]string, len(allNodes))
for id, n := range allNodes {
m[id] = string(pb.NewServerAddressFromDataNode(n))
}
return m
}
// planECDestinations places all shards of the volume via the shared ecbalancer
// policy and returns the per-disk destination plans plus, parallel to them, the
// shard ids ecbalancer.Place assigned to each disk (so createECTargets and the
// capacity reservations use the real assignment, not a round-robin guess).
//
// snap is the cycle-wide placement snapshot, built once by the caller and reused
// across volumes: Place reserves the shards it assigns into it, so later volumes
// in the same detection cycle see the reduced capacity. Rebuilding it per volume
// is O(volumes × topology) and times out on large clusters.
//
// Encode is lenient (PlaceDurabilityFirst): it relaxes caps/anti-affinity/RP as
// needed rather than fail, and prefers the source disk type but spills if that
// type can't hold every shard. rp is the resolved replica placement (may be nil).
func planECDestinations(snap *ecbalancer.Topology, nodeAddresses map[string]string, metric *types.VolumeHealthMetrics, ecConfig *Config, rp *super_block.ReplicaPlacement, dataShards, parityShards int) (*topology.MultiDestinationPlan, [][]uint32, error) {
if snap == nil {
return nil, nil, fmt.Errorf("EC placement snapshot not available")
}
if dataShards <= 0 || parityShards <= 0 {
return nil, nil, fmt.Errorf("invalid EC ratio: dataShards=%d parityShards=%d", dataShards, parityShards)
}
totalShards := dataShards + parityShards
// Survive losing one disk: each disk holds at most parityShards shards,
// so we need at least ceil(totalShards / parityShards) disks.
minTotalDisks := (totalShards + parityShards - 1) / parityShards
expectedShardSize := uint64(metric.Size) / uint64(dataShards)
// Encode is greenfield: any EC shards already present for this volume are stale
// leftovers from a prior failed attempt, which the task deletes
// (cleanupStaleEcShards) before distributing the new shards. Release them so they
// don't occupy capacity or skew anti-affinity / per-disk caps during planning.
snap.ReleaseVolumeShards(metric.Collection, metric.VolumeID)
need := make([]int, totalShards)
for i := range need {
need[i] = i
}
res, err := snap.Place(metric.VolumeID, metric.Collection, need, ecbalancer.Constraints{
DiskType: metric.DiskType,
DiskTypePolicy: ecbalancer.DiskTypePrefer,
PreferredTags: ecConfig.PreferredTags,
ReplicaPlacement: rp,
Ratio: func(string) (int, int) { return dataShards, parityShards },
}, ecbalancer.PlaceDurabilityFirst)
if err != nil {
return nil, nil, err
}
if res.SpilledToOtherDiskType {
glog.Warningf("EC volume %d: placed shards outside preferred disk type %q", metric.VolumeID, metric.DiskType)
}
if res.SpilledOutsidePreferredTags {
glog.Warningf("EC volume %d: placed shards outside preferred tags %v", metric.VolumeID, ecConfig.PreferredTags)
}
if len(res.Relaxed) > 0 {
// Encode is best-effort (PlaceDurabilityFirst): it relaxes these constraints
// rather than defer when the cluster can't satisfy them. Surface it so a tight
// replica placement isn't silently weakened; rebalancing tightens the spread.
glog.Warningf("EC volume %d: placed with relaxed constraints %v; replica placement not fully satisfied (rebalancing will adjust)", metric.VolumeID, res.Relaxed)
}
// Group the per-shard destinations into one plan per (node,disk), iterating
// shard ids in order for determinism.
type diskGroup struct {
node, rack, dc string
diskID uint32
shards []uint32
}
type diskKey struct {
node string
diskID uint32
}
groups := make(map[diskKey]*diskGroup, totalShards)
order := make([]diskKey, 0, totalShards)
for sid := 0; sid < totalShards; sid++ {
d, ok := res.Destinations[sid]
if !ok {
return nil, nil, fmt.Errorf("EC volume %d: shard %d was not placed", metric.VolumeID, sid)
}
key := diskKey{node: d.Node, diskID: d.DiskID}
g := groups[key]
if g == nil {
g = &diskGroup{node: d.Node, rack: d.Rack, dc: d.DataCenter, diskID: d.DiskID}
groups[key] = g
order = append(order, key)
}
g.shards = append(g.shards, uint32(sid))
}
if len(order) < minTotalDisks {
return nil, nil, fmt.Errorf("placed onto %d disks, but EC %d+%d needs at least %d so no disk holds more than %d shards",
len(order), dataShards, parityShards, minTotalDisks, parityShards)
}
var plans []*topology.DestinationPlan
shardsPerPlan := make([][]uint32, 0, len(order))
rackCount := make(map[string]int)
dcCount := make(map[string]int)
for _, key := range order {
g := groups[key]
targetAddress, ok := nodeAddresses[g.node]
if !ok {
return nil, nil, fmt.Errorf("failed to resolve address for target server %s", g.node)
}
plans = append(plans, &topology.DestinationPlan{
TargetNode: g.node,
TargetAddress: targetAddress,
TargetDisk: g.diskID,
TargetRack: g.rack,
TargetDC: g.dc,
ExpectedSize: expectedShardSize,
})
shardsPerPlan = append(shardsPerPlan, g.shards)
rackCount[fmt.Sprintf("%s:%s", g.dc, g.rack)]++
dcCount[g.dc]++
}
glog.V(1).Infof("Planned EC destinations for volume %d (size=%d bytes): expected shard size=%d bytes, %d shards across %d disks, %d racks, %d DCs",
metric.VolumeID, metric.Size, expectedShardSize, totalShards, len(plans), len(rackCount), len(dcCount))
return &topology.MultiDestinationPlan{
Plans: plans,
TotalShards: totalShards,
SuccessfulRack: len(rackCount),
SuccessfulDCs: len(dcCount),
}, shardsPerPlan, nil
}
// createECTargets builds TaskTargets from the per-disk plans and the shard ids
// ecbalancer.Place assigned to each (shardsPerPlan is parallel to multiPlan.Plans).
func createECTargets(multiPlan *topology.MultiDestinationPlan, shardsPerPlan [][]uint32) []*worker_pb.TaskTarget {
targets := make([]*worker_pb.TaskTarget, 0, len(multiPlan.Plans))
for i, plan := range multiPlan.Plans {
shardIDs := shardsPerPlan[i]
targets = append(targets, &worker_pb.TaskTarget{
Node: plan.TargetAddress,
DiskId: plan.TargetDisk,
Rack: plan.TargetRack,
DataCenter: plan.TargetDC,
ShardIds: shardIDs,
EstimatedSize: plan.ExpectedSize,
})
glog.V(2).Infof("EC planning: target %s disk %d assigned shards %v", plan.TargetNode, plan.TargetDisk, shardIDs)
}
return targets
}
// convertTaskSourcesToProtobuf converts topology.TaskSourceSpec to worker_pb.TaskSource
func convertTaskSourcesToProtobuf(sources []topology.TaskSourceSpec, volumeID uint32, activeTopology *topology.ActiveTopology) ([]*worker_pb.TaskSource, error) {
var protobufSources []*worker_pb.TaskSource
for _, source := range sources {
serverAddress, err := workerutil.ResolveServerAddress(source.ServerID, activeTopology)
if err != nil {
return nil, fmt.Errorf("failed to resolve address for source server %s: %v", source.ServerID, err)
}
pbSource := &worker_pb.TaskSource{
Node: serverAddress,
DiskId: source.DiskID,
DataCenter: source.DataCenter,
Rack: source.Rack,
}
// Convert storage impact to estimated size
if source.EstimatedSize != nil {
pbSource.EstimatedSize = uint64(*source.EstimatedSize)
}
// Populated ShardIds is the wire-level marker that flags an
// EC-shard cleanup source; the worker routes it through
// cleanupStaleEcShards and skips it in getReplicas.
switch source.CleanupType {
case topology.CleanupVolumeReplica:
pbSource.VolumeId = volumeID
case topology.CleanupECShards:
pbSource.VolumeId = volumeID
pbSource.ShardIds = append([]uint32(nil), source.ShardIds...)
}
protobufSources = append(protobufSources, pbSource)
}
return protobufSources, nil
}
// createECTaskParams creates clean EC task parameters (destinations now in unified targets).
// sourceDiskType is forwarded to VolumeEcShardsMount so the resulting EC volume
// reports under the source's disk type rather than the target location's (#9423).
func createECTaskParams(dataShards, parityShards int, sourceDiskType string) *worker_pb.ErasureCodingTaskParams {
return &worker_pb.ErasureCodingTaskParams{
DataShards: int32(dataShards),
ParityShards: int32(parityShards),
SourceDiskType: sourceDiskType,
}
}
// findVolumeReplicaLocations finds all replica locations (server + disk) for the specified volume
// Uses O(1) indexed lookup for optimal performance on large clusters.
func findVolumeReplicaLocations(activeTopology *topology.ActiveTopology, volumeID uint32, collection string) []topology.VolumeReplica {
if activeTopology == nil {
return nil
}
return activeTopology.GetVolumeLocations(volumeID, collection)
}
// findExistingECShards finds existing EC shards for a volume (from previous failed EC attempts)
// Uses O(1) indexed lookup for optimal performance on large clusters.
func findExistingECShards(activeTopology *topology.ActiveTopology, volumeID uint32, collection string) []topology.VolumeReplica {
if activeTopology == nil {
return nil
}
return activeTopology.GetECShardLocations(volumeID, collection)
}
// cleanupOrphanSourceReplicas deletes any regular volume replicas still
// present in the topology for (volumeID, collection) after re-verifying that
// the full EC shard set is intact. Caller must hold expectedShards equal to
// the configured totalShards count. Issues VolumeDelete RPC to each replica
// server's address — that endpoint only touches the regular volume on the
// targeted server, never EC shards (those live in a separate store path).
// The cluster-wide `volume.delete` shell command is what would have nuked
// the EC shards too; the targeted RPC used here is safe by construction.
// Returns the count of replicas successfully deleted plus any error.
func cleanupOrphanSourceReplicas(ctx context.Context, clusterInfo *types.ClusterInfo, metric *types.VolumeHealthMetrics, expectedShards int) (int, error) {
if clusterInfo == nil || clusterInfo.ActiveTopology == nil {
return 0, fmt.Errorf("active topology unavailable")
}
if clusterInfo.GrpcDialOption == nil {
return 0, fmt.Errorf("grpc dial option unavailable")
}
// Re-verify shard completeness right before acting. Defensive: detection
// processes many volumes sequentially and the topology snapshot we built
// at start-of-detection could have lost shards in between (a volume
// server going down between iterations). Refusing to delete the source
// when we can no longer prove the shards are complete is the safer
// failure mode — the source replica is the only complete copy.
actualShards := countExistingEcShardsForVolume(clusterInfo.ActiveTopology, metric.VolumeID, metric.Collection)
if actualShards < expectedShards {
return 0, fmt.Errorf("EC shard set shrank between detection and cleanup (%d < %d); refusing to delete source replica", actualShards, expectedShards)
}
replicas := findVolumeReplicaLocations(clusterInfo.ActiveTopology, metric.VolumeID, metric.Collection)
if len(replicas) == 0 {
return 0, nil
}
deleted := 0
var deleteErrors []string
for _, replica := range replicas {
serverAddress := replica.ServerID
var isReadOnly bool
err := operation.WithVolumeServerClient(false, pb.ServerAddress(serverAddress), clusterInfo.GrpcDialOption,
func(client volume_server_pb.VolumeServerClient) error {
// Re-probe before deleting: only a still-readonly source replica is
// safe to remove. One that came back writable may have accepted
// writes the EC shards do not contain, so deleting it loses data.
status, statusErr := client.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: metric.VolumeID})
if statusErr != nil {
return statusErr
}
isReadOnly = status.GetIsReadOnly()
if !isReadOnly {
return nil
}
_, deleteErr := client.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{
VolumeId: metric.VolumeID,
OnlyEmpty: false,
})
return deleteErr
})
if err != nil {
deleteErrors = append(deleteErrors, fmt.Sprintf("server %s: %v", serverAddress, err))
continue
}
if !isReadOnly {
glog.Warningf("EC Detection: source replica for volume %d on %s is writable; not deleting (may hold writes the EC shards lack)", metric.VolumeID, serverAddress)
continue
}
deleted++
glog.V(1).Infof("EC Detection: deleted orphan regular replica for volume %d on %s", metric.VolumeID, serverAddress)
}
if len(deleteErrors) > 0 {
return deleted, fmt.Errorf("%d of %d replica delete(s) failed: %s", len(deleteErrors), len(replicas), strings.Join(deleteErrors, "; "))
}
return deleted, nil
}
// countExistingEcShardsForVolume counts (volumeID, collection) EC shards in the
// topology, counting only the single largest encode generation; see
// ec.CountExistingEcShardsForVolume for the generation semantics.
func countExistingEcShardsForVolume(activeTopology *topology.ActiveTopology, volumeID uint32, collection string) int {
if activeTopology == nil {
return 0
}
return ec.CountExistingEcShardsForVolume(activeTopology.GetTopologyInfo(), volumeID, collection)
}

View File

@@ -0,0 +1,93 @@
package erasure_coding
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPlanECDestinationsPrefersSourceDiskType_FullCluster covers the
// #9423 happy path through the worker plumbing: every node carries both
// an HDD and an SSD disk, so the cluster has enough SSD slots to fully
// satisfy a 10+4 placement. With metric.DiskType="ssd", every selected
// destination must land on an SSD disk.
func TestPlanECDestinationsPrefersSourceDiskType_FullCluster(t *testing.T) {
// 14 nodes × 2 disks per node (hdd + ssd) — enough SSD slots alone
// for a 10+4 layout with one-shard-per-(server,disk) diversity.
activeTopology := buildActiveTopology(t, erasure_coding.TotalShardsCount, []string{"hdd", "ssd"}, 100, 0, "")
metric := &types.VolumeHealthMetrics{
VolumeID: 1,
Server: "10.0.0.1:8080",
Size: 100 * 1024 * 1024,
Collection: "",
DiskType: "ssd", // the property being plumbed end-to-end
}
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
nodeAddresses := buildNodeAddressMap(activeTopology)
plan, _, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
require.NoError(t, err)
require.Len(t, plan.Plans, erasure_coding.TotalShardsCount)
// buildActiveTopology assigns DiskID by index into the diskTypes
// slice, so SSD is DiskID 1. Every plan entry must point at an SSD
// disk — anything else means metric.DiskType was dropped on the way
// to the placement layer.
for _, p := range plan.Plans {
assert.Equalf(t, uint32(1), p.TargetDisk,
"target %s diskID = %d, want SSD disk (DiskID=1)", p.TargetNode, p.TargetDisk)
}
}
// TestPlanECDestinationsSpillsToOtherDiskType_WhenPreferredScarce pins
// the prefer-with-spillover policy: only one node in the cluster has an
// SSD disk; the rest are HDD-only. A 10+4 placement must still succeed
// (no hard fail), filling the one SSD slot first and the remaining 13
// from HDD nodes. Without the spillover behavior this would either
// error out or starve the placement.
func TestPlanECDestinationsSpillsToOtherDiskType_WhenPreferredScarce(t *testing.T) {
// Start with every node carrying both HDD and SSD, then strip SSD
// from all but the first node so the SSD pool is too small alone.
activeTopology := buildActiveTopology(t, erasure_coding.TotalShardsCount, []string{"hdd", "ssd"}, 100, 0, "")
topo := activeTopology.GetTopologyInfo()
for _, dc := range topo.DataCenterInfos {
for _, rack := range dc.RackInfos {
for i, node := range rack.DataNodeInfos {
if i == 0 {
continue // keep the first node's SSD
}
delete(node.DiskInfos, "ssd")
}
}
}
require.NoError(t, activeTopology.UpdateTopology(topo))
metric := &types.VolumeHealthMetrics{
VolumeID: 2,
Server: "10.0.0.1:8080",
Size: 100 * 1024 * 1024,
Collection: "",
DiskType: "ssd",
}
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
nodeAddresses := buildNodeAddressMap(activeTopology)
plan, _, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
require.NoError(t, err)
require.Len(t, plan.Plans, erasure_coding.TotalShardsCount)
// Exactly one SSD target (the only SSD node), the rest on HDD.
ssdHits := 0
for _, p := range plan.Plans {
if p.TargetDisk == 1 {
ssdHits++
}
}
assert.Equalf(t, 1, ssdHits,
"expected exactly 1 SSD placement (the only SSD node in the cluster), got %d", ssdHits)
}

View File

@@ -0,0 +1,92 @@
package erasure_coding
import (
"fmt"
"testing"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/stretchr/testify/require"
)
type ecShardSet struct {
bits uint32
encodeTsNs int64
}
func ecBits(shardIds ...int) uint32 {
var b uint32
for _, id := range shardIds {
b |= uint32(1) << uint(id)
}
return b
}
// buildEcTopology builds an ActiveTopology where each shard set lives on its own
// node, so countExistingEcShardsForVolume can be exercised across encode
// generations spread over different disks.
func buildEcTopology(t *testing.T, vid uint32, collection string, sets []ecShardSet) *topology.ActiveTopology {
t.Helper()
at := topology.NewActiveTopology(10)
nodes := make([]*master_pb.DataNodeInfo, 0, len(sets))
for i, set := range sets {
nodes = append(nodes, &master_pb.DataNodeInfo{
Id: fmt.Sprintf("10.0.0.%d:8080", i+1),
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {
DiskId: 0,
MaxVolumeCount: 100,
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
{Id: vid, Collection: collection, EcIndexBits: set.bits, EncodeTsNs: set.encodeTsNs},
},
},
},
})
}
topologyInfo := &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{Id: "rack1", DataNodeInfos: nodes}},
}},
}
require.NoError(t, at.UpdateTopology(topologyInfo))
return at
}
// Two interrupted encode runs whose shard sets overlap in count must NOT be
// unioned into a false-complete set. countExistingEcShardsForVolume returns the
// largest single generation's shard count, not the cross-run union.
func TestCountExistingEcShards_DoesNotUnionAcrossGenerations(t *testing.T) {
const vid = uint32(42)
// run A holds shards 0-6 (7 shards, encode_ts 1); run B holds shards 7-13
// (7 shards, encode_ts 2). The union would be all 14; the per-generation max
// is 7, so the orphan-source delete trigger (>= 14) must not fire.
at := buildEcTopology(t, vid, "", []ecShardSet{
{bits: ecBits(0, 1, 2, 3, 4, 5, 6), encodeTsNs: 1},
{bits: ecBits(7, 8, 9, 10, 11, 12, 13), encodeTsNs: 2},
})
require.Equal(t, 7, countExistingEcShardsForVolume(at, vid, ""))
}
// A single complete generation still counts as complete, so genuine stuck-source
// cleanup keeps working.
func TestCountExistingEcShards_SingleCompleteGeneration(t *testing.T) {
const vid = uint32(43)
at := buildEcTopology(t, vid, "", []ecShardSet{
{bits: ecBits(0, 1, 2, 3, 4, 5, 6), encodeTsNs: 5},
{bits: ecBits(7, 8, 9, 10, 11, 12, 13), encodeTsNs: 5},
})
require.Equal(t, 14, countExistingEcShardsForVolume(at, vid, ""))
}
// Shards from pre-upgrade servers report encode_ts_ns==0; a complete 0-generation
// set is still recognized as complete (its own bucket).
func TestCountExistingEcShards_PreUpgradeZeroGeneration(t *testing.T) {
const vid = uint32(44)
at := buildEcTopology(t, vid, "", []ecShardSet{
{bits: ecBits(0, 1, 2, 3, 4, 5, 6), encodeTsNs: 0},
{bits: ecBits(7, 8, 9, 10, 11, 12, 13), encodeTsNs: 0},
})
require.Equal(t, 14, countExistingEcShardsForVolume(at, vid, ""))
}

View File

@@ -0,0 +1,499 @@
package erasure_coding
import (
"context"
"fmt"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPlanECDestinationsUsesPlanner(t *testing.T) {
activeTopology := buildActiveTopology(t, 7, []string{"hdd", "ssd"}, 100, 0, "")
metric := &types.VolumeHealthMetrics{
VolumeID: 1,
Server: "10.0.0.1:8080",
Size: 100 * 1024 * 1024,
Collection: "",
}
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
nodeAddresses := buildNodeAddressMap(activeTopology)
plan, shardsPerPlan, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
require.NoError(t, err)
require.NotNil(t, plan)
requireAllShardsPlaced(t, plan, shardsPerPlan)
}
// TestDetectionSkipsWhenECShardsAlreadyExist guards against issue #9448: a
// regular replica that survived a previous successful EC encode (source
// delete didn't clean it up for some reason) gets re-proposed for encoding,
// the new encode collides with the already-mounted shards on the targets
// ("ec volume %d is mounted; refusing overwrite"), and detection loops
// forever on the same volume. Detection must see the existing shards and
// skip the volume so an admin can clean it up out-of-band.
//
// The guard fires ONLY when the EC shard set is complete (count >=
// totalShards), so a partially-distributed previous attempt still falls
// through to the existing recovery branch in the encode path.
func TestDetectionSkipsWhenECShardsAlreadyExist(t *testing.T) {
const volumeID uint32 = 42
activeTopology := buildStuckSourceTopology(t, volumeID, erasure_coding.TotalShardsCount)
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
metrics := buildStuckSourceMetrics(volumeID, "127.0.0.1:8080")
results, hasMore, err := Detection(context.Background(), metrics, clusterInfo, NewDefaultConfig(), 0)
require.NoError(t, err)
require.False(t, hasMore)
require.Empty(t, results, "stuck source replica with all EC shards present must not yield a new encoding proposal")
}
// TestDetectionAllowsRegularReplicaWhenShardsPartial covers the partial-EC
// branch of the #9448 guard: when fewer than totalShards exist, the volume
// is allowed to flow through to the normal encoding path so the existing
// recovery branch (the `existingECShards` block in the encode arm) can fold
// the partial shards into the new task. A bug here would either (a) skip
// the volume entirely or (b) emit a proposal that later collides on the
// mounted shards.
func TestDetectionAllowsRegularReplicaWhenShardsPartial(t *testing.T) {
const volumeID uint32 = 43
activeTopology := buildStuckSourceTopology(t, volumeID, erasure_coding.DataShardsCount-1)
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
metrics := buildStuckSourceMetrics(volumeID, "127.0.0.1:8080")
results, _, err := Detection(context.Background(), metrics, clusterInfo, NewDefaultConfig(), 0)
require.NoError(t, err)
// Partial shards are not a "stuck source" — the encode arm must keep
// its chance to either propose a fresh task that folds the partial
// shards into cleanup, or fail planning on the constrained topology.
// We don't require len(results) > 0 because the constrained topology
// (one disk per node, the orphaned shards already taking slots) can
// legitimately fail destination planning. The assertion that matters
// is: the #9448 guard did NOT silently swallow the volume into a
// skippedAlreadyEC counter, and any emitted result is still an EC
// task and not a no-op.
for _, r := range results {
require.Equal(t, types.TaskTypeErasureCoding, r.TaskType, "any emitted result should still be an EC task, not a no-op")
}
}
// buildStuckSourceTopology constructs a topology that mimics the #9448 stuck
// state: a regular volume replica on node 0 plus `presentShardCount` EC
// shards distributed across nodes 0..presentShardCount-1.
func buildStuckSourceTopology(t *testing.T, volumeID uint32, presentShardCount int) *topology.ActiveTopology {
t.Helper()
require.LessOrEqual(t, presentShardCount, erasure_coding.TotalShardsCount)
activeTopology := topology.NewActiveTopology(10)
nodes := make([]*master_pb.DataNodeInfo, 0, erasure_coding.TotalShardsCount)
for i := 0; i < erasure_coding.TotalShardsCount; i++ {
nodeID := fmt.Sprintf("127.0.0.1:%d", 8080+i)
diskInfo := &master_pb.DiskInfo{
DiskId: 0,
VolumeCount: 1,
MaxVolumeCount: 100,
}
if i < presentShardCount {
diskInfo.EcShardInfos = []*master_pb.VolumeEcShardInformationMessage{{
Id: volumeID,
Collection: "",
EcIndexBits: uint32(1) << uint(i),
DiskId: 0,
}}
}
if i == 0 {
diskInfo.VolumeInfos = []*master_pb.VolumeInformationMessage{{
Id: volumeID,
DiskId: 0,
DiskType: "hdd",
Size: 200 * 1024 * 1024,
}}
}
nodes = append(nodes, &master_pb.DataNodeInfo{
Id: nodeID,
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": diskInfo},
})
}
require.NoError(t, activeTopology.UpdateTopology(&master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{
Id: "rack1",
DataNodeInfos: nodes,
}},
}},
}))
return activeTopology
}
// buildStuckSourceMetrics returns a metric that already satisfies the EC
// criteria (Age, FullnessRatio, Size), with `Age` derived from `LastModified`
// so the two fields stay consistent for any reader.
func buildStuckSourceMetrics(volumeID uint32, server string) []*types.VolumeHealthMetrics {
lastModified := time.Now().Add(-2 * time.Hour)
return []*types.VolumeHealthMetrics{{
VolumeID: volumeID,
Server: server,
Size: 200 * 1024 * 1024,
Collection: "",
FullnessRatio: 0.96,
LastModified: lastModified,
Age: time.Since(lastModified),
}}
}
// TestCountExistingEcShardsForVolume verifies that the helper walks the
// EcIndexBits bitmap (not just len(EcShardInfos)) so it correctly counts
// distinct shard ids even when a single info entry on one disk carries
// multiple shards.
func TestCountExistingEcShardsForVolume(t *testing.T) {
const volumeID uint32 = 99
activeTopology := topology.NewActiveTopology(10)
require.NoError(t, activeTopology.UpdateTopology(&master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{
Id: "rack1",
DataNodeInfos: []*master_pb.DataNodeInfo{
{
Id: "127.0.0.1:8080",
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {
DiskId: 0,
MaxVolumeCount: 100,
// One info entry, three shards present (ids 0, 2, 5).
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{
Id: volumeID,
Collection: "",
EcIndexBits: (uint32(1) << 0) | (uint32(1) << 2) | (uint32(1) << 5),
DiskId: 0,
}},
},
},
},
{
Id: "127.0.0.1:8081",
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {
DiskId: 0,
MaxVolumeCount: 100,
// One info entry, one shard (id 3) — overlaps with neither.
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{
Id: volumeID,
Collection: "",
EcIndexBits: uint32(1) << 3,
DiskId: 0,
}},
},
},
},
},
}},
}},
}))
assert.Equal(t, 4, countExistingEcShardsForVolume(activeTopology, volumeID, ""))
assert.Equal(t, 0, countExistingEcShardsForVolume(activeTopology, volumeID, "other-collection"))
assert.Equal(t, 0, countExistingEcShardsForVolume(nil, volumeID, ""))
}
func TestDetectionContextCancellation(t *testing.T) {
activeTopology := buildActiveTopology(t, 5, []string{"hdd", "ssd"}, 50, 0, "")
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
metrics := buildVolumeMetricsForIDs(50)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err := Detection(ctx, metrics, clusterInfo, NewDefaultConfig(), 0)
require.ErrorIs(t, err, context.Canceled)
}
// The admin UI collection filter takes a list, and the two mode sentinels must
// not be read as a collection name.
func TestDetectionCollectionFilter(t *testing.T) {
for _, tc := range []struct {
filter string
want int
}{
{filter: "", want: 3},
{filter: "ALL_COLLECTIONS", want: 3},
{filter: "collection-a", want: 3},
{filter: "collection-a,collection-b", want: 3},
{filter: " collection-b , collection-a ", want: 3},
{filter: "collection-*", want: 3},
{filter: "collection-b,collection-c", want: 0},
{filter: "collection-ab", want: 0},
} {
t.Run(tc.filter, func(t *testing.T) {
// A fresh topology per case: planned destinations reserve capacity.
activeTopology := buildActiveTopology(t, erasure_coding.TotalShardsCount, []string{"hdd"}, 20, 0, "collection-a")
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
metrics := buildVolumeMetricsForIDs(3)
for _, metric := range metrics {
metric.Collection = "collection-a"
}
config := NewDefaultConfig()
config.CollectionFilter = tc.filter
results, _, err := Detection(context.Background(), metrics, clusterInfo, config, 0)
require.NoError(t, err)
assert.Len(t, results, tc.want)
})
}
}
func TestDetectionMaxResultsHonorsLimit(t *testing.T) {
// One node per shard so each shard gets its own disk (#9369).
activeTopology := buildActiveTopology(t, erasure_coding.TotalShardsCount, []string{"hdd"}, 20, 0, "")
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
metrics := buildVolumeMetricsForIDs(3)
results, hasMore, err := Detection(context.Background(), metrics, clusterInfo, NewDefaultConfig(), 1)
require.NoError(t, err)
assert.Len(t, results, 1)
assert.True(t, hasMore)
}
// #9369: 7 servers × 2 physical HDDs must yield 14 distinct (server, disk_id)
// destinations, not 7 destinations doubled up on the same disk.
func TestPlanECDestinationsSpreadsAcrossPhysicalDisks(t *testing.T) {
const numServers = 7
const disksPerServer = 2
activeTopology := topology.NewActiveTopology(10)
nodes := make([]*master_pb.DataNodeInfo, 0, numServers)
for i := 1; i <= numServers; i++ {
volumeInfos := make([]*master_pb.VolumeInformationMessage, 0, disksPerServer)
for d := uint32(0); d < disksPerServer; d++ {
volumeInfos = append(volumeInfos, &master_pb.VolumeInformationMessage{
Id: uint32(i*100 + int(d)),
DiskId: d,
DiskType: "hdd",
})
}
nodes = append(nodes, &master_pb.DataNodeInfo{
Id: fmt.Sprintf("127.0.0.1:%d", 8080+i),
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {
DiskId: 0,
VolumeCount: int64(disksPerServer),
MaxVolumeCount: 200,
VolumeInfos: volumeInfos,
},
},
})
}
require.NoError(t, activeTopology.UpdateTopology(&master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{
Id: "rack1",
DataNodeInfos: nodes,
}},
}},
}))
metric := &types.VolumeHealthMetrics{
VolumeID: 42,
Server: "127.0.0.1:8081",
Size: 100 * 1024 * 1024,
Collection: "",
}
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
nodeAddresses := buildNodeAddressMap(activeTopology)
plan, shardsPerPlan, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
require.NoError(t, err)
require.NotNil(t, plan)
requireAllShardsPlaced(t, plan, shardsPerPlan)
}
func TestPlanECDestinationsFailsWithInsufficientCapacity(t *testing.T) {
activeTopology := buildActiveTopology(t, 1, []string{"hdd"}, 1, 1, "")
metric := &types.VolumeHealthMetrics{
VolumeID: 2,
Server: "10.0.0.1:8080",
Size: 10 * 1024 * 1024,
Collection: "",
}
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
nodeAddresses := buildNodeAddressMap(activeTopology)
_, _, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
require.Error(t, err)
}
// #9586: with fewer single-disk servers than total shards, EC must still plan
// by packing several shards onto a disk (ec.encode's "4,4,3,3" fallback) rather
// than refusing. The reporter has 8 single-disk servers across 3 racks and a
// 10+4 scheme — 8 disks for 14 shards. minTotalDisks (ceil(14/4)=4) keeps any
// disk under parityShards shards, so durability holds.
func TestPlanECDestinationsPacksWhenFewerDisksThanShards(t *testing.T) {
const numServers = 8
// rack3 holds 4 servers, rack1 and rack2 hold 2 each, mirroring the report.
racks := []string{"rack3", "rack3", "rack3", "rack3", "rack1", "rack1", "rack2", "rack2"}
activeTopology := topology.NewActiveTopology(10)
rackNodes := make(map[string][]*master_pb.DataNodeInfo)
for i := 0; i < numServers; i++ {
nodeID := fmt.Sprintf("192.168.1.%d:%d", 143+i/3, 8080+i)
rackNodes[racks[i]] = append(rackNodes[racks[i]], &master_pb.DataNodeInfo{
Id: nodeID,
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {
DiskId: 0,
VolumeCount: 1,
MaxVolumeCount: 200,
VolumeInfos: []*master_pb.VolumeInformationMessage{{
Id: uint32(i + 1),
DiskId: 0,
DiskType: "hdd",
}},
},
},
})
}
rackInfos := make([]*master_pb.RackInfo, 0, len(rackNodes))
for _, rackID := range []string{"rack1", "rack2", "rack3"} {
rackInfos = append(rackInfos, &master_pb.RackInfo{Id: rackID, DataNodeInfos: rackNodes[rackID]})
}
require.NoError(t, activeTopology.UpdateTopology(&master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{Id: "dc1", RackInfos: rackInfos}},
}))
metric := &types.VolumeHealthMetrics{
VolumeID: 4569,
Server: "192.168.1.145:8081",
Size: 100 * 1024 * 1024,
Collection: "",
}
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
nodeAddresses := buildNodeAddressMap(activeTopology)
plan, shardsPerPlan, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
require.NoError(t, err)
require.NotNil(t, plan)
// Packed onto the available disks: more than one shard per disk but never more
// than the 8 disks, and at least the durability floor of distinct disks.
require.LessOrEqual(t, len(plan.Plans), numServers)
require.GreaterOrEqual(t, len(plan.Plans), (erasure_coding.TotalShardsCount+erasure_coding.ParityShardsCount-1)/erasure_coding.ParityShardsCount)
// createECTargets must cover all 14 shards exactly once, packing onto the
// available disks without any disk exceeding parityShards shards.
targets := createECTargets(plan, shardsPerPlan)
require.Equal(t, len(plan.Plans), len(targets))
seenShards := make(map[uint32]bool)
for _, target := range targets {
require.LessOrEqual(t, len(target.ShardIds), erasure_coding.ParityShardsCount,
"no disk may hold more than parityShards shards, else losing it loses the volume")
for _, shardId := range target.ShardIds {
require.False(t, seenShards[shardId], "shard %d assigned to more than one target", shardId)
seenShards[shardId] = true
}
}
require.Len(t, seenShards, erasure_coding.TotalShardsCount, "every shard must be placed exactly once")
}
// requireAllShardsPlaced asserts every EC shard landed exactly once, on a distinct
// (node,disk) target, with no disk holding more than parityShards shards (so losing
// any one disk cannot lose the volume). shardsPerPlan is parallel to plan.Plans.
func requireAllShardsPlaced(t *testing.T, plan *topology.MultiDestinationPlan, shardsPerPlan [][]uint32) {
t.Helper()
require.Equal(t, len(plan.Plans), len(shardsPerPlan), "one shard list per plan entry")
keys := make(map[string]bool, len(plan.Plans))
seen := make(map[uint32]bool)
for i, p := range plan.Plans {
key := fmt.Sprintf("%s:%d", p.TargetNode, p.TargetDisk)
require.False(t, keys[key], "duplicate (node,disk) target %s", key)
keys[key] = true
require.LessOrEqual(t, len(shardsPerPlan[i]), erasure_coding.ParityShardsCount,
"disk %s holds %d shards, over parityShards", key, len(shardsPerPlan[i]))
for _, s := range shardsPerPlan[i] {
require.False(t, seen[s], "shard %d placed more than once", s)
seen[s] = true
}
}
require.Len(t, seen, erasure_coding.TotalShardsCount, "every shard must be placed exactly once")
}
func buildVolumeMetricsForIDs(count int) []*types.VolumeHealthMetrics {
metrics := make([]*types.VolumeHealthMetrics, 0, count)
now := time.Now()
for id := 1; id <= count; id++ {
metrics = append(metrics, &types.VolumeHealthMetrics{
VolumeID: uint32(id),
Server: "10.0.0.1:8080",
Size: 200 * 1024 * 1024,
Collection: "",
FullnessRatio: 0.96,
LastModified: now.Add(-2 * time.Hour),
Age: 2 * time.Hour,
})
}
return metrics
}
func buildActiveTopology(t *testing.T, nodeCount int, diskTypes []string, maxVolumeCount, usedVolumeCount int64, collection string) *topology.ActiveTopology {
t.Helper()
activeTopology := topology.NewActiveTopology(10)
nodes := make([]*master_pb.DataNodeInfo, 0, nodeCount)
for i := 1; i <= nodeCount; i++ {
diskInfos := make(map[string]*master_pb.DiskInfo)
for diskIndex, diskType := range diskTypes {
used := usedVolumeCount
if used > maxVolumeCount {
used = maxVolumeCount
}
volumeInfos := make([]*master_pb.VolumeInformationMessage, 0, 200)
for vid := 1; vid <= 200; vid++ {
volumeInfos = append(volumeInfos, &master_pb.VolumeInformationMessage{
Id: uint32(vid),
Collection: collection,
DiskId: uint32(diskIndex),
})
}
diskInfos[diskType] = &master_pb.DiskInfo{
DiskId: uint32(diskIndex),
VolumeCount: used,
MaxVolumeCount: maxVolumeCount,
VolumeInfos: volumeInfos,
}
}
nodes = append(nodes, &master_pb.DataNodeInfo{
Id: fmt.Sprintf("10.0.0.%d:8080", i),
DiskInfos: diskInfos,
})
}
topologyInfo := &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{
{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{
{
Id: "rack1",
DataNodeInfos: nodes,
},
},
},
},
}
require.NoError(t, activeTopology.UpdateTopology(topologyInfo))
return activeTopology
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
package erasure_coding
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// One reachable replica + one unreachable: the reachable delete still
// succeeds, and the function surfaces an error naming the failure.
func TestDeleteOriginalVolumeSurfacesReplicaFailures(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(91842)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 918420, 0x91842042)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
[]byte("delete-surface-content-for-issue-9184"))
_ = framework.ReadAllAndClose(t, uploadResp)
require.Equal(t, http.StatusCreated, uploadResp.StatusCode)
task := NewErasureCodingTask(
"delete-surface-fix",
clusterHarness.VolumeServerAddress(),
volumeID,
"",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
unreachable := "127.0.0.1:1"
task.sources = []*worker_pb.TaskSource{
{
Node: clusterHarness.VolumeServerAddress(),
VolumeId: volumeID,
},
{
Node: unreachable,
VolumeId: volumeID,
},
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := task.deleteOriginalVolume(ctx)
require.Error(t, err, "deleteOriginalVolume must surface replica delete failures (#9184)")
require.Contains(t, err.Error(), unreachable,
"returned error should name the replica that failed: %v", err)
require.True(t,
strings.Contains(err.Error(), "failed to delete"),
"returned error should describe what failed: %v", err)
_, statusErr := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
require.Error(t, statusErr,
"reachable replica %d should have been deleted before failure was surfaced", volumeID)
}
func TestDeleteOriginalVolumeSucceedsWhenAllReplicasReachable(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(91844)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 918440, 0x91844042)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
[]byte("delete-happy-path-content-for-issue-9184"))
_ = framework.ReadAllAndClose(t, uploadResp)
require.Equal(t, http.StatusCreated, uploadResp.StatusCode)
task := NewErasureCodingTask(
"delete-happy-path",
clusterHarness.VolumeServerAddress(),
volumeID,
"",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
task.sources = []*worker_pb.TaskSource{
{Node: clusterHarness.VolumeServerAddress(), VolumeId: volumeID},
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
require.NoError(t, task.deleteOriginalVolume(ctx))
_, statusErr := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
require.Error(t, statusErr, "volume %d should be gone after successful delete", volumeID)
}

View File

@@ -0,0 +1,95 @@
package erasure_coding
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestReplicasPendingDelete(t *testing.T) {
replicas := []string{"a:8080", "b:8080", "c:8080"}
require.Equal(t, replicas, replicasPendingDelete(replicas, nil),
"nil swept set leaves the list unchanged")
require.ElementsMatch(t, []string{"a:8080", "c:8080"},
replicasPendingDelete(replicas, map[string]bool{"b:8080": true}),
"swept node is excluded")
require.Empty(t,
replicasPendingDelete(replicas, map[string]bool{"a:8080": true, "b:8080": true, "c:8080": true}),
"all swept yields nothing left to delete")
}
// The sweep leaves a node in place only when VolumeDelete(OnlyEmpty) reports the
// volume holds data or no longer exists. An unreachable node leaves its state
// unknown and must propagate so the encode fails rather than force-deleting it
// later over the shared .vif.
func TestIsExpectedSweepSkip(t *testing.T) {
require.True(t, isExpectedSweepSkip(errors.New("DeleteVolume 5: volume not empty")))
require.True(t, isExpectedSweepSkip(errors.New("volume 5 not found on disk")))
require.False(t, isExpectedSweepSkip(errors.New("rpc error: code = Unavailable desc = connection refused")))
require.False(t, isExpectedSweepSkip(errors.New("context deadline exceeded")))
}
// The pre-distribute sweep must delete a 0-byte stub replica (so its shared
// <collection>_<vid>.vif can't be clobbered by a later original-delete and
// can't shadow the incoming EC files) while leaving a data-bearing replica
// untouched — VolumeDelete(OnlyEmpty=true) is the guard. A data-bearing
// replica is deleted later, only after the EC shard set is verified.
func TestSweepEmptyReplicasDeletesStubKeepsData(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
server := clusterHarness.VolumeServerAddress()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// An empty allocated volume is a superblock-only stub.
const stubVol = uint32(9479)
framework.AllocateVolume(t, grpcClient, stubVol, "ec-stub")
stubTask := newSweepTaskForTest(server, stubVol, "ec-stub", dialOption)
require.NoError(t, stubTask.sweepEmptyReplicas(ctx))
require.True(t, stubTask.emptyReplicasDeleted[server], "stub server should be recorded as swept")
_, err := grpcClient.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{VolumeId: stubVol})
require.Error(t, err, "empty stub volume must be deleted by the sweep")
// A volume holding data must survive the sweep.
const dataVol = uint32(9480)
framework.AllocateVolume(t, grpcClient, dataVol, "ec-data")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(dataVol, 948000, 0x9480CAFE)
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("real-data-keep-me"))
_ = framework.ReadAllAndClose(t, upResp)
require.Equal(t, http.StatusCreated, upResp.StatusCode)
dataTask := newSweepTaskForTest(server, dataVol, "ec-data", dialOption)
require.NoError(t, dataTask.sweepEmptyReplicas(ctx), "a not-empty refusal is expected, not an error")
require.False(t, dataTask.emptyReplicasDeleted[server], "data-bearing server must not be recorded as swept")
_, err = grpcClient.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{VolumeId: dataVol})
require.NoError(t, err, "data-bearing volume must survive the sweep")
}
func newSweepTaskForTest(server string, volumeID uint32, collection string, dialOption grpc.DialOption) *ErasureCodingTask {
task := NewErasureCodingTask("sweep-"+collection, server, volumeID, collection, dialOption)
task.sources = []*worker_pb.TaskSource{{Node: server, VolumeId: volumeID}}
return task
}

View File

@@ -0,0 +1,159 @@
package erasure_coding
import (
"context"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// End-to-end on two servers: a volume with a real replica on server A and a
// 0-byte stub replica of the same id on server B (an interrupted-encode
// leftover). After a full EC encode the cluster must end in exactly one valid
// layout — the complete shard set split across A and B, each with .ecx/.vif —
// and every wrong file must be gone: both regular .dat files (source deleted
// after verify, stub swept before distribute), no shard on the wrong server,
// and B's shared <collection>_<vid>.vif intact rather than clobbered by the
// stub's delete.
func TestEcEncodeLeavesRightFilesAndRemovesStubAndSource(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartMultiVolumeCluster(t, matrix.P1(), 2)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
const (
volumeID = uint32(9490)
collection = "ec-e2e"
)
addrA := serverAddress(cluster, 0)
addrB := serverAddress(cluster, 1)
connA, clientA := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(0))
defer connA.Close()
connB, clientB := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(1))
defer connB.Close()
// Server A: real source replica with data.
framework.AllocateVolume(t, clientA, volumeID, collection)
httpClient := framework.NewHTTPClient()
for i := 0; i < 8; i++ {
fid := framework.NewFileID(volumeID, uint64(948000+i), 0x9490CA00+uint32(i))
payload := make([]byte, 4096)
for j := range payload {
payload[j] = byte(i + 1)
}
resp := framework.UploadBytes(t, httpClient, cluster.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, resp)
require.Equal(t, http.StatusCreated, resp.StatusCode)
}
// Server B: empty stub replica of the same volume id.
framework.AllocateVolume(t, clientB, volumeID, collection)
dataShards := int(erasure_coding.DataShardsCount)
totalShards := int(erasure_coding.DataShardsCount + erasure_coding.ParityShardsCount)
aShards := shardRange(0, dataShards) // 0..DataShardsCount-1 on A
bShards := shardRange(dataShards, totalShards) // parity range on B
task := NewErasureCodingTask("ec-e2e", addrA, volumeID, collection, dialOption)
params := &worker_pb.TaskParams{
VolumeId: volumeID,
Collection: collection,
Sources: []*worker_pb.TaskSource{
{Node: addrA, VolumeId: volumeID},
{Node: addrB, VolumeId: volumeID},
},
Targets: []*worker_pb.TaskTarget{
{Node: addrA, ShardIds: aShards},
{Node: addrB, ShardIds: bShards},
},
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
DataShards: erasure_coding.DataShardsCount,
ParityShards: erasure_coding.ParityShardsCount,
WorkingDir: t.TempDir(),
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
require.NoError(t, task.Execute(ctx, params))
dirA := filepath.Join(cluster.BaseDir(), "volume0")
dirB := filepath.Join(cluster.BaseDir(), "volume1")
base := fmt.Sprintf("%s_%d", collection, volumeID)
// Both original regular volumes are gone: A's source deleted after verify,
// B's stub swept before distribute.
requireAbsent(t, dirA, base+".dat")
requireAbsent(t, dirA, base+".idx")
requireAbsent(t, dirB, base+".dat")
requireAbsent(t, dirB, base+".idx")
// Each server holds exactly its assigned shards, plus index/info sidecars.
for _, id := range aShards {
requirePresent(t, dirA, fmt.Sprintf("%s.ec%02d", base, id))
}
for _, id := range bShards {
requireAbsent(t, dirA, fmt.Sprintf("%s.ec%02d", base, id))
}
requirePresent(t, dirA, base+".ecx")
requirePresent(t, dirA, base+".vif")
for _, id := range bShards {
requirePresent(t, dirB, fmt.Sprintf("%s.ec%02d", base, id))
}
for _, id := range aShards {
requireAbsent(t, dirB, fmt.Sprintf("%s.ec%02d", base, id))
}
requirePresent(t, dirB, base+".ecx")
// The shared .vif must survive on B: the stub was deleted before the EC
// files landed, so deleteOriginalVolume never ran removeVolumeFiles there.
requirePresent(t, dirB, base+".vif")
}
// serverAddress builds the SeaweedFS ip:httpPort.grpcPort address the worker's
// gRPC client decodes, from the multi-cluster's separate admin and grpc ports.
func serverAddress(c *framework.MultiVolumeCluster, index int) string {
_, grpcPort, err := net.SplitHostPort(c.VolumeGRPCAddress(index))
if err != nil {
panic(err)
}
return c.VolumeAdminAddress(index) + "." + grpcPort
}
func shardRange(start, end int) []uint32 {
ids := make([]uint32, 0, end-start)
for i := start; i < end; i++ {
ids = append(ids, uint32(i))
}
return ids
}
func requirePresent(t *testing.T, dir, name string) {
t.Helper()
_, err := os.Stat(filepath.Join(dir, name))
require.NoError(t, err, "expected %s to be present in %s", name, dir)
}
func requireAbsent(t *testing.T, dir, name string) {
t.Helper()
_, err := os.Stat(filepath.Join(dir, name))
require.True(t, os.IsNotExist(err), "expected %s to be absent in %s, stat err=%v", name, dir, err)
}

View File

@@ -0,0 +1,173 @@
package erasure_coding
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Reproduces the issue-9478 volume-13 layout end to end on a multi-server,
// multi-disk cluster and asserts the cluster converges to exactly one valid EC
// layout. Before the encode the volume is in the stuck state: a real .dat
// source, a 0-byte stub replica left by an interrupted encode, and partial
// stale EC shards from that attempt. After the worker runs (sweep stubs ->
// clear stale shards -> distribute -> mount -> verify -> delete originals):
//
// - every assigned shard is present on its target disk, with .ecx/.vif
// alongside it (the EC volume's info file survives even when shards land on
// the source's own disk);
// - no regular .dat/.idx remains anywhere — the source is deleted after
// verify, the stub was swept before distribute;
// - the stale shards that aren't part of the new assignment are gone.
func TestEcEncodeJulorLayoutConverges(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
const disksPerServer = 2
cluster := framework.StartMultiVolumeClusterWithDisks(t, matrix.P1(), 3, disksPerServer)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
const (
volumeID = uint32(9500)
collection = "ec-julor"
)
// S0: real source. S1: 0-byte stub + EC target. S2: pure EC target.
const (
srcServer = 0
stubServer = 1
)
addr := func(i int) string { return serverAddress(cluster, i) }
conns := make([]*grpc.ClientConn, 3)
clients := make([]volume_server_pb.VolumeServerClient, 3)
for i := 0; i < 3; i++ {
conns[i], clients[i] = framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(i))
defer conns[i].Close()
}
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
// S0: real source replica with data.
framework.AllocateVolume(t, clients[srcServer], volumeID, collection)
httpClient := framework.NewHTTPClient()
for i := 0; i < 8; i++ {
fid := framework.NewFileID(volumeID, uint64(950000+i), 0x9500CA00+uint32(i))
payload := make([]byte, 4096)
for j := range payload {
payload[j] = byte(i + 1)
}
resp := framework.UploadBytes(t, httpClient, cluster.VolumeAdminURL(srcServer), fid, payload)
_ = framework.ReadAllAndClose(t, resp)
require.Equal(t, http.StatusCreated, resp.StatusCode)
}
// Partial stale EC shards from a previous interrupted encode, mounted on the
// source. cleanupStaleEcShards must clear them before re-distribute.
_, err := clients[srcServer].VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID, Collection: collection,
})
require.NoError(t, err)
staleShards := []uint32{11, 12, 13}
_, err = clients[srcServer].VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID, Collection: collection, ShardIds: staleShards,
})
require.NoError(t, err)
// S1: a 0-byte stub replica of the same volume (interrupted-encode leftover).
framework.AllocateVolume(t, clients[stubServer], volumeID, collection)
// Plan: 14 shards spread across all three servers and both disks. The source
// server (S0) receives shards too, so its regular volume coexists with EC on
// the same disk before deletion.
targets := []*worker_pb.TaskTarget{
{Node: addr(0), DiskId: 0, ShardIds: shardRange(0, 3)}, // S0 d0: 0,1,2
{Node: addr(0), DiskId: 1, ShardIds: shardRange(3, 5)}, // S0 d1: 3,4
{Node: addr(1), DiskId: 0, ShardIds: shardRange(5, 8)}, // S1 d0: 5,6,7
{Node: addr(1), DiskId: 1, ShardIds: shardRange(8, 10)}, // S1 d1: 8,9
{Node: addr(2), DiskId: 0, ShardIds: shardRange(10, 12)}, // S2 d0: 10,11
{Node: addr(2), DiskId: 1, ShardIds: shardRange(12, 14)}, // S2 d1: 12,13
}
task := NewErasureCodingTask("ec-julor", addr(srcServer), volumeID, collection, dialOption)
params := &worker_pb.TaskParams{
VolumeId: volumeID,
Collection: collection,
Sources: []*worker_pb.TaskSource{
{Node: addr(srcServer), VolumeId: volumeID}, // real source
{Node: addr(stubServer), VolumeId: volumeID}, // 0-byte stub
{Node: addr(srcServer), VolumeId: volumeID, ShardIds: staleShards}, // stale EC shards to clear
},
Targets: targets,
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
DataShards: erasure_coding.DataShardsCount,
ParityShards: erasure_coding.ParityShardsCount,
WorkingDir: t.TempDir(),
},
},
}
require.NoError(t, task.Execute(ctx, params))
base := fmt.Sprintf("%s_%d", collection, volumeID)
// Shards are distributed per-server; the disk within a server is auto-selected
// when DiskId is 0, and on a multi-disk server shards can spread to a sibling
// disk while the .ecx/.vif index lives on one of them (the cross-disk EC
// layout). So assert per server (aggregate across its disks): each server
// holds exactly its assigned shards, its .ecx/.vif survive on at least one
// disk (the info file is not stripped, even on the source's own disk), and no
// regular .dat/.idx remains. Stale shards 11-13 mounted on the source must be
// gone — they are not in S0's assignment.
serverShards := map[int][]uint32{
0: shardRange(0, 5), // S0 (source): 0..4
1: shardRange(5, 10), // S1 (stub): 5..9
2: shardRange(10, 14), // S2 (target): 10..13
}
allShards := map[uint32]bool{}
for server, want := range serverShards {
found := map[uint32]bool{}
hasEcx, hasVif := false, false
for d := 0; d < disksPerServer; d++ {
dir := cluster.VolumeDiskDir(server, d)
requireAbsent(t, dir, base+".dat")
requireAbsent(t, dir, base+".idx")
for id := 0; id < erasure_coding.TotalShardsCount; id++ {
if ecFileExists(dir, fmt.Sprintf("%s.ec%02d", base, id)) {
found[uint32(id)] = true
allShards[uint32(id)] = true
}
}
hasEcx = hasEcx || ecFileExists(dir, base+".ecx")
hasVif = hasVif || ecFileExists(dir, base+".vif")
}
gotIDs := make([]uint32, 0, len(found))
for id := range found {
gotIDs = append(gotIDs, id)
}
require.ElementsMatch(t, want, gotIDs, "server %d should hold exactly its assigned shards", server)
require.True(t, hasEcx, "server %d must keep its .ecx", server)
require.True(t, hasVif, "server %d must keep its .vif (not stripped by the original-volume delete)", server)
}
require.Len(t, allShards, erasure_coding.TotalShardsCount, "the full shard set must be present across the cluster")
}
func ecFileExists(dir, name string) bool {
_, err := os.Stat(filepath.Join(dir, name))
return err == nil
}

View File

@@ -0,0 +1,94 @@
package erasure_coding
import (
"context"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// cleanupOrphanSourceReplicas must not delete a source replica that has come
// back writable: it may hold writes the EC shards do not contain. A still
// readonly replica is the genuine orphan and is deleted.
func TestCleanupOrphanSkipsWritableSourceReplica(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(955)
framework.AllocateVolume(t, grpcClient, volumeID, "")
clusterInfo := &types.ClusterInfo{
ActiveTopology: buildSingleNodeTopology(t, clusterHarness.VolumeServerAddress(), volumeID),
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
metric := &types.VolumeHealthMetrics{VolumeID: volumeID, Collection: ""}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Writable source replica: must be left intact.
deleted, err := cleanupOrphanSourceReplicas(ctx, clusterInfo, metric, 0)
require.NoError(t, err)
require.Equal(t, 0, deleted, "a writable source replica must not be deleted")
require.True(t, volumeExists(t, grpcClient, volumeID), "writable source volume must still exist")
// Mark readonly: now it is a genuine orphan and must be deleted.
_, err = grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: volumeID})
require.NoError(t, err)
deleted, err = cleanupOrphanSourceReplicas(ctx, clusterInfo, metric, 0)
require.NoError(t, err)
require.Equal(t, 1, deleted, "a readonly orphan source replica must be deleted")
require.False(t, volumeExists(t, grpcClient, volumeID), "readonly orphan source volume must be deleted")
}
func volumeExists(t *testing.T, client volume_server_pb.VolumeServerClient, volumeID uint32) bool {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := client.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
return err == nil
}
// buildSingleNodeTopology builds an ActiveTopology with one node (at serverAddr)
// holding the regular volume, so cleanupOrphanSourceReplicas resolves the
// replica to the live test server and its gRPC calls reach it.
func buildSingleNodeTopology(t *testing.T, serverAddr string, volumeID uint32) *topology.ActiveTopology {
t.Helper()
at := topology.NewActiveTopology(10)
topologyInfo := &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{
Id: "rack1",
DataNodeInfos: []*master_pb.DataNodeInfo{{
Id: serverAddr,
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {
DiskId: 0,
VolumeInfos: []*master_pb.VolumeInformationMessage{
{Id: volumeID, Collection: "", DiskId: 0},
},
},
},
}},
}},
}},
}
require.NoError(t, at.UpdateTopology(topologyInfo))
return at
}

View File

@@ -0,0 +1,231 @@
package erasure_coding
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Reproduces the swtest interrupted-encode scenario at the task boundary: a
// previous encode of this volume was cut mid-distribute, leaving partial EC
// shards mounted on a destination. When a fresh encode task starts, its Step 0
// preflight (ensureCleanEcStart) must clear those shards before any
// destructive step, so distribute's ReceiveFile is not refused and no orphan
// shards survive to confuse the volume-server loader or make detection refuse
// the volume. The destination is named only as a target (no source row) to
// prove the preflight reaches the full write set, not just shard-bearing
// sources.
func TestEnsureCleanEcStartClearsStaleShardsBeforeEncode(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const (
volumeID = uint32(94782)
collection = "ec-preflight-invariant"
)
framework.AllocateVolume(t, grpcClient, volumeID, collection)
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 9478200, 0x9478BEEF)
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
[]byte("payload-for-preflight-stale-ec-cleanup"))
_ = framework.ReadAllAndClose(t, upResp)
require.Equal(t, http.StatusCreated, upResp.StatusCode)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID, Collection: collection,
})
require.NoError(t, err)
// A half-finished previous distribute left a partial shard set mounted.
staleShards := []uint32{0, 1, 2}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID, Collection: collection,
ShardIds: staleShards,
})
require.NoError(t, err)
shardPath := makeTinyEcShardFile(t)
// Precondition for the reproduction: the mounted partial EC blocks a fresh
// ReceiveFile via the mounted-volume guard.
err = sendShardViaReceiveFile(ctx, grpcClient, volumeID, collection, 0, shardPath)
require.Error(t, err, "expected ReceiveFile to be refused while stale EC volume is mounted")
task := NewErasureCodingTask(
"preflight-invariant",
clusterHarness.VolumeServerAddress(),
volumeID,
collection,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
// Named only as a target — no source row for this node. The preflight must
// still clear its stale shards.
task.targets = []*worker_pb.TaskTarget{
{Node: clusterHarness.VolumeServerAddress(), VolumeId: volumeID, ShardIds: []uint32{0}},
}
require.NoError(t, task.ensureCleanEcStart(ctx),
"Step 0 preflight must clear stale EC shards at task start")
_, infoErr := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{VolumeId: volumeID})
require.Error(t, infoErr, "stale EC volume must be gone after the preflight")
require.NoError(t,
sendShardViaReceiveFile(ctx, grpcClient, volumeID, collection, 0, shardPath),
"ReceiveFile must succeed once the preflight has cleared the stale shards")
}
// A post-distribute failure that has no successor to clean up after it (a
// single-attempt job, or the last of a retry series) must leave nothing behind:
// rollbackDistribute tears down the shards this attempt wrote while leaving the
// source volume intact for a future re-encode. Reproduces the swtest finding
// that a terminally-failed encode stranded orphan shards.
func TestRollbackDistributeClearsShardsAndKeepsSource(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const (
volumeID = uint32(94783)
collection = "ec-rollback-distribute"
)
framework.AllocateVolume(t, grpcClient, volumeID, collection)
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 9478300, 0x9478D00D)
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
[]byte("payload-for-rollback-distribute"))
_ = framework.ReadAllAndClose(t, upResp)
require.Equal(t, http.StatusCreated, upResp.StatusCode)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID, Collection: collection,
})
require.NoError(t, err)
// Shards this attempt "distributed" and mounted on the destination.
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID, Collection: collection,
ShardIds: []uint32{0, 1, 2, 3},
})
require.NoError(t, err)
task := NewErasureCodingTask(
"rollback-distribute",
clusterHarness.VolumeServerAddress(),
volumeID,
collection,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
task.targets = []*worker_pb.TaskTarget{
{Node: clusterHarness.VolumeServerAddress(), VolumeId: volumeID, ShardIds: []uint32{0}},
}
// Simulate the failure path after distribute began.
task.rollbackDistribute(ctx)
_, infoErr := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{VolumeId: volumeID})
require.Error(t, infoErr, "rollback must tear down the shards this attempt distributed")
// The source normal volume must survive so a future re-encode can proceed.
_, statusErr := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
require.NoError(t, statusErr, "rollback must leave the source volume intact")
}
// A malformed plan (no targets) must be rejected by the preflight before the
// source is marked readonly or copied, so a bad plan cannot leave the source
// fenced readonly with nothing to show for it. Pure precondition check: no
// cluster and no RPC.
func TestEnsureCleanEcStartRejectsMissingTargets(t *testing.T) {
task := NewErasureCodingTask(
"preflight-no-targets",
"10.0.0.1:8080",
42,
"c",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
// No targets set.
err := task.ensureCleanEcStart(context.Background())
require.Error(t, err, "preflight must reject an encode with no shard targets")
require.True(t, strings.Contains(err.Error(), "no EC shard targets"),
"error must name the missing-targets invariant, got: %v", err)
}
// A non-empty target slice whose entries are malformed (nil, empty Node, or no
// assigned shards) must also be rejected before the source is marked readonly —
// cleanupStaleEcShards silently skips such entries, so a length check alone
// would let the encode proceed with nothing to distribute to.
func TestEnsureCleanEcStartRejectsMalformedTarget(t *testing.T) {
cases := map[string][]*worker_pb.TaskTarget{
"nil target": {nil},
"empty node": {{Node: "", VolumeId: 42, ShardIds: []uint32{0}}},
"no shards": {{Node: "10.0.0.2:8080", VolumeId: 42}},
"one good one bad": {
{Node: "10.0.0.2:8080", VolumeId: 42, ShardIds: []uint32{0}},
{Node: "", VolumeId: 42, ShardIds: []uint32{1}},
},
}
for name, targets := range cases {
t.Run(name, func(t *testing.T) {
task := NewErasureCodingTask(
"preflight-malformed-target",
"10.0.0.1:8080",
42,
"c",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
task.targets = targets
err := task.ensureCleanEcStart(context.Background())
require.Error(t, err, "preflight must reject a malformed target")
require.True(t, strings.Contains(err.Error(), "malformed EC shard target"),
"error must name the malformed-target invariant, got: %v", err)
})
}
}
// A plan with targets but no source replica and no assigned server must be
// rejected before any destructive step.
func TestEnsureCleanEcStartRejectsMissingSource(t *testing.T) {
task := NewErasureCodingTask(
"preflight-no-source",
"", // no assigned server
42,
"c",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
task.targets = []*worker_pb.TaskTarget{
{Node: "10.0.0.2:8080", VolumeId: 42, ShardIds: []uint32{0}},
}
// No sources and no server.
err := task.ensureCleanEcStart(context.Background())
require.Error(t, err, "preflight must reject an encode with no source replica")
require.True(t, strings.Contains(err.Error(), "no source replica"),
"error must name the missing-source invariant, got: %v", err)
}

View File

@@ -0,0 +1,363 @@
package erasure_coding
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Reproduces a stuck re-encode: partial EC shards mounted on a destination
// from a previous failed encode cause ReceiveFile to refuse with the
// mounted-volume guard. cleanupStaleEcShards must clear them so the next
// ReceiveFile lands.
func TestCleanupStaleEcShardsBeforeDistribute(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const (
volumeID = uint32(9478)
collection = "ec-9478-xserver"
)
framework.AllocateVolume(t, grpcClient, volumeID, collection)
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 947800, 0x9478CAFE)
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
[]byte("payload-for-cross-server-stale-ec-cleanup"))
_ = framework.ReadAllAndClose(t, upResp)
require.Equal(t, http.StatusCreated, upResp.StatusCode)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID, Collection: collection,
})
require.NoError(t, err)
// Partial subset mimics a half-finished previous distribute: shards
// mounted on the destination with no .dat to anchor a same-store prune.
staleShards := []uint32{0, 1, 2}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID, Collection: collection,
ShardIds: staleShards,
})
require.NoError(t, err)
shardPath := makeTinyEcShardFile(t)
// Pre-cleanup: the mounted partial EC blocks ReceiveFile.
err = sendShardViaReceiveFile(ctx, grpcClient, volumeID, collection, 0, shardPath)
require.Error(t, err, "expected ReceiveFile to be refused while EC volume is mounted")
require.True(t,
strings.Contains(err.Error(), "is mounted") ||
strings.Contains(err.Error(), "unmount before ReceiveFile"),
"expected refusal to name the mounted-volume guard, got: %v", err)
// ShardIds set marks this as an EC-shard cleanup source: cleanup will
// target it; getReplicas must skip it.
task := NewErasureCodingTask(
"stale-ec-xserver",
clusterHarness.VolumeServerAddress(),
volumeID,
collection,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
task.dataShards = erasure_coding.DataShardsCount
task.parityShards = erasure_coding.ParityShardsCount
task.sources = []*worker_pb.TaskSource{
{
Node: clusterHarness.VolumeServerAddress(),
VolumeId: volumeID,
ShardIds: staleShards,
},
}
require.NoError(t, task.cleanupStaleEcShards(ctx))
_, infoErr := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{VolumeId: volumeID})
require.Error(t, infoErr, "EC volume should be gone after cleanupStaleEcShards")
require.NoError(t,
sendShardViaReceiveFile(ctx, grpcClient, volumeID, collection, 0, shardPath),
"ReceiveFile must succeed after cleanup")
require.Empty(t, task.getReplicas(),
"EC-shard sources must not appear in replica delete list")
}
// The destination has more shards mounted than t.sources lists — simulates
// detection-time topology missing shards (e.g., a prior attempt's mount
// hadn't heartbeated yet, or different shards live on a sibling disk). The
// cleanup must clear FindEcVolume regardless, by issuing unmount/delete
// over the full shard range, so the next ReceiveFile lands.
func TestCleanupStaleEcShardsClearsShardsBeyondSources(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const (
volumeID = uint32(94780)
collection = "ec-9478-beyond"
)
framework.AllocateVolume(t, grpcClient, volumeID, collection)
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 9478001, 0x9478FACE)
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
[]byte("payload-for-shards-beyond-sources"))
_ = framework.ReadAllAndClose(t, upResp)
require.Equal(t, http.StatusCreated, upResp.StatusCode)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID, Collection: collection,
})
require.NoError(t, err)
// Five shards mounted on the destination.
mountedShards := []uint32{0, 1, 2, 3, 4}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID, Collection: collection,
ShardIds: mountedShards,
})
require.NoError(t, err)
// Detection only "saw" two of them — the rest must still get cleared.
knownToDetection := []uint32{0, 1}
task := NewErasureCodingTask(
"stale-ec-beyond-sources",
clusterHarness.VolumeServerAddress(),
volumeID,
collection,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
task.dataShards = erasure_coding.DataShardsCount
task.parityShards = erasure_coding.ParityShardsCount
task.sources = []*worker_pb.TaskSource{
{
Node: clusterHarness.VolumeServerAddress(),
VolumeId: volumeID,
ShardIds: knownToDetection,
},
}
require.NoError(t, task.cleanupStaleEcShards(ctx))
_, infoErr := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{VolumeId: volumeID})
require.Error(t, infoErr,
"all mounted shards must be cleared even though detection only listed a subset")
shardPath := makeTinyEcShardFile(t)
require.NoError(t,
sendShardViaReceiveFile(ctx, grpcClient, volumeID, collection, 4, shardPath),
"ReceiveFile for a shard outside detection's snapshot must succeed after cleanup")
}
// Cleanup also targets fresh destinations from t.targets even when no
// EC-shard source row exists for them. This catches concurrent-attempt
// fallout where a previous worker mounted shards on a node we're now
// writing to but the topology snapshot is stale.
func TestCleanupStaleEcShardsCoversTargetsWithoutSources(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const (
volumeID = uint32(94781)
collection = "ec-9478-targets-only"
)
framework.AllocateVolume(t, grpcClient, volumeID, collection)
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 9478101, 0x947811CE)
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
[]byte("payload-for-targets-only-cleanup"))
_ = framework.ReadAllAndClose(t, upResp)
require.Equal(t, http.StatusCreated, upResp.StatusCode)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID, Collection: collection,
})
require.NoError(t, err)
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID, Collection: collection,
ShardIds: []uint32{0, 1},
})
require.NoError(t, err)
task := NewErasureCodingTask(
"stale-ec-targets-only",
clusterHarness.VolumeServerAddress(),
volumeID,
collection,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
task.dataShards = erasure_coding.DataShardsCount
task.parityShards = erasure_coding.ParityShardsCount
// No sources. The destination is named only as a target — the cleanup
// must still reach it.
task.targets = []*worker_pb.TaskTarget{
{Node: clusterHarness.VolumeServerAddress(), VolumeId: volumeID, ShardIds: []uint32{0}},
}
require.NoError(t, task.cleanupStaleEcShards(ctx))
_, infoErr := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{VolumeId: volumeID})
require.Error(t, infoErr,
"target-only destinations must also be cleaned even without a source row")
}
// Cleanup is a no-op when sources carry only the regular .dat replica.
func TestCleanupStaleEcShardsSkipsRegularReplicas(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(9479)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
task := NewErasureCodingTask(
"no-stale-ec",
clusterHarness.VolumeServerAddress(),
volumeID,
"",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
task.sources = []*worker_pb.TaskSource{
{Node: clusterHarness.VolumeServerAddress(), VolumeId: volumeID},
}
require.NoError(t, task.cleanupStaleEcShards(ctx))
_, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
require.NoError(t, err, "regular volume must remain untouched")
}
// makeTinyEcShardFile writes a placeholder payload — the mounted-volume
// guard fires before any content is consumed, so the bytes don't need to
// be a real shard.
func makeTinyEcShardFile(t *testing.T) string {
t.Helper()
p := filepath.Join(t.TempDir(), "shard.bin")
require.NoError(t, os.WriteFile(p, []byte("ec-shard-placeholder"), 0o600))
return p
}
// sendShardViaReceiveFile streams a shard file through the same ReceiveFile
// gRPC the EC worker uses, returning the server's reply error verbatim.
func sendShardViaReceiveFile(
ctx context.Context,
client volume_server_pb.VolumeServerClient,
volumeID uint32,
collection string,
shardID uint32,
filePath string,
) error {
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
stream, err := client.ReceiveFile(ctx)
if err != nil {
return err
}
if err := stream.Send(&volume_server_pb.ReceiveFileRequest{
Data: &volume_server_pb.ReceiveFileRequest_Info{
Info: &volume_server_pb.ReceiveFileInfo{
VolumeId: volumeID,
Ext: erasure_coding.ToExt(int(shardID)),
Collection: collection,
IsEcVolume: true,
ShardId: shardID,
FileSize: uint64(info.Size()),
},
},
}); err != nil {
return err
}
buf := make([]byte, 32*1024)
for {
n, readErr := f.Read(buf)
if n > 0 {
if err := stream.Send(&volume_server_pb.ReceiveFileRequest{
Data: &volume_server_pb.ReceiveFileRequest_FileContent{
FileContent: buf[:n],
},
}); err != nil {
return err
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return readErr
}
}
resp, err := stream.CloseAndRecv()
if err != nil {
return err
}
if resp.Error != "" {
return &receiveFileServerError{msg: resp.Error}
}
return nil
}
type receiveFileServerError struct{ msg string }
func (e *receiveFileServerError) Error() string { return e.msg }

View File

@@ -0,0 +1,249 @@
package erasure_coding
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/volume_info"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestCopyVolumeFilesToWorkerUsesCurrentCompactionRevision(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(951)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
liveFID := framework.NewFileID(volumeID, 1001, 0x1111AAAA)
liveUploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), liveFID, []byte("live-payload-for-ec-copy"))
_ = framework.ReadAllAndClose(t, liveUploadResp)
require.Equal(t, http.StatusCreated, liveUploadResp.StatusCode)
deletedFID := framework.NewFileID(volumeID, 1002, 0x2222BBBB)
deletedUploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), deletedFID, []byte("deleted-payload-for-vacuum"))
_ = framework.ReadAllAndClose(t, deletedUploadResp)
require.Equal(t, http.StatusCreated, deletedUploadResp.StatusCode)
deleteReq, err := http.NewRequest(http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+deletedFID, nil)
require.NoError(t, err)
deleteResp := framework.DoRequest(t, httpClient, deleteReq)
_ = framework.ReadAllAndClose(t, deleteResp)
require.Equal(t, http.StatusAccepted, deleteResp.StatusCode)
compactVolumeOnce(t, grpcClient, volumeID)
task := NewErasureCodingTask(
"copy-after-compaction",
clusterHarness.VolumeServerAddress(),
volumeID,
"",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
require.NoError(t, task.markReplicasReadonly(ctx))
fileStatus, err := task.readSourceVolumeFileStatus(ctx)
require.NoError(t, err)
require.Greater(t, fileStatus.GetCompactionRevision(), uint32(0))
localFiles, err := task.copyVolumeFilesToWorker(ctx, t.TempDir())
require.NoError(t, err)
datInfo, err := os.Stat(localFiles["dat"])
require.NoError(t, err)
require.Equal(t, int64(fileStatus.GetDatFileSize()), datInfo.Size())
idxInfo, err := os.Stat(localFiles["idx"])
require.NoError(t, err)
require.Equal(t, int64(fileStatus.GetIdxFileSize()), idxInfo.Size())
}
// markReplicasReadonly must persist the readonly mark into the .vif so a
// source-server restart cannot silently reopen the volume to writes that the
// EC shards would not contain.
func TestMarkReplicasReadonlyPersists(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(954)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 3001, 0x4444DDDD)
resp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("payload-for-readonly-persist"))
_ = framework.ReadAllAndClose(t, resp)
require.Equal(t, http.StatusCreated, resp.StatusCode)
task := NewErasureCodingTask(
"ec-readonly-persist",
clusterHarness.VolumeServerAddress(),
volumeID,
"",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
require.NoError(t, task.markReplicasReadonly(ctx))
vifPath := filepath.Join(clusterHarness.BaseDir(), "volume", fmt.Sprintf("%d.vif", volumeID))
vi, _, found, err := volume_info.MaybeLoadVolumeInfo(vifPath)
require.NoError(t, err)
require.True(t, found, "volume must have a .vif after a persisted readonly mark")
require.True(t, vi.ReadOnly, "markReplicasReadonly must persist ReadOnly=true into the .vif")
}
// The worker-local encode path must stamp the EC ratio and an encode identity
// into the .vif, or the read guard is silently off for worker-encoded volumes.
func TestGenerateEcShardsLocallyStampsEncodeIdentity(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(952)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 2001, 0x3333CCCC)
resp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("payload-for-ec-encode-identity"))
_ = framework.ReadAllAndClose(t, resp)
require.Equal(t, http.StatusCreated, resp.StatusCode)
task := NewErasureCodingTask(
"ec-encode-identity",
clusterHarness.VolumeServerAddress(),
volumeID,
"",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
require.NoError(t, task.markReplicasReadonly(ctx))
localFiles, err := task.copyVolumeFilesToWorker(ctx, t.TempDir())
require.NoError(t, err)
shardFiles, err := task.generateEcShardsLocally(localFiles, t.TempDir())
require.NoError(t, err)
vifPath := shardFiles["vif"]
require.NotEmpty(t, vifPath, "generate must produce a .vif")
vi, _, found, err := volume_info.MaybeLoadVolumeInfo(vifPath)
require.NoError(t, err)
require.True(t, found)
require.NotNil(t, vi.EcShardConfig, "worker .vif must record the EC config")
require.Greater(t, vi.EcShardConfig.DataShards, uint32(0))
require.Greater(t, vi.EcShardConfig.ParityShards, uint32(0))
require.NotZero(t, vi.EcShardConfig.EncodeTsNs, "worker-encoded .vif must carry an encode identity")
}
// A fenced run must stamp the admin-issued generation (t.encodeTsNs) verbatim into
// the distributed .vif, so the generation the worker distributes equals the one it
// later sends on the teardown RPCs and the volume-server fence compares like-for-like.
func TestGenerateEcShardsLocallyUsesAdminGeneration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(957)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 2002, 0x5555EEEE)
resp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("payload-for-admin-generation"))
_ = framework.ReadAllAndClose(t, resp)
require.Equal(t, http.StatusCreated, resp.StatusCode)
task := NewErasureCodingTask(
"ec-admin-generation",
clusterHarness.VolumeServerAddress(),
volumeID,
"",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
const adminGeneration int64 = 1_700_000_000_000_000_321
task.encodeTsNs = adminGeneration
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
require.NoError(t, task.markReplicasReadonly(ctx))
localFiles, err := task.copyVolumeFilesToWorker(ctx, t.TempDir())
require.NoError(t, err)
shardFiles, err := task.generateEcShardsLocally(localFiles, t.TempDir())
require.NoError(t, err)
vi, _, found, err := volume_info.MaybeLoadVolumeInfo(shardFiles["vif"])
require.NoError(t, err)
require.True(t, found)
require.NotNil(t, vi.EcShardConfig)
require.Equal(t, adminGeneration, vi.EcShardConfig.EncodeTsNs, "fenced run must stamp the admin generation verbatim, not a local timestamp")
}
func compactVolumeOnce(t *testing.T, grpcClient volume_server_pb.VolumeServerClient, volumeID uint32) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
compactStream, err := grpcClient.VacuumVolumeCompact(ctx, &volume_server_pb.VacuumVolumeCompactRequest{
VolumeId: volumeID,
})
require.NoError(t, err)
for {
_, err = compactStream.Recv()
if err == io.EOF {
break
}
require.NoError(t, err)
}
_, err = grpcClient.VacuumVolumeCommit(ctx, &volume_server_pb.VacuumVolumeCommitRequest{
VolumeId: volumeID,
})
require.NoError(t, err)
_, err = grpcClient.VacuumVolumeCleanup(ctx, &volume_server_pb.VacuumVolumeCleanupRequest{
VolumeId: volumeID,
})
require.NoError(t, err)
}

View File

@@ -0,0 +1,43 @@
package erasure_coding
import (
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// isStubReplica reports whether a regular replica's .dat is too small to hold
// any data — at most a bare superblock. An interrupted encode or copy can
// leave a 0-byte .dat, or write the 8-byte superblock and then fail; either
// way the file is not an encode source and must not be counted toward
// size/fullness checks.
func isStubReplica(size uint64) bool {
return size <= uint64(super_block.SuperBlockSize)
}
// selectCanonicalMetric picks the metric that drives the encode checks and
// names the encode source server. It prefers the lowest-server credible
// replica — data-bearing and not already EC — so a 0-byte stub or a leftover
// EC shard set sharing the volume id cannot become canonical and strand the
// volume (a stub trips the min-size gate; an EC metric trips the IsECVolume
// guard, hiding the volume from both orphan-source cleanup and re-encode).
// When nothing is credible there is nothing to encode, so the lowest-server
// metric is returned unchanged and the downstream gates make the skip
// decision as before.
func selectCanonicalMetric(group []*types.VolumeHealthMetrics) *types.VolumeHealthMetrics {
var lowest, credible *types.VolumeHealthMetrics
for _, m := range group {
if lowest == nil || m.Server < lowest.Server {
lowest = m
}
if m.IsECVolume || isStubReplica(m.Size) {
continue
}
if credible == nil || m.Server < credible.Server {
credible = m
}
}
if credible != nil {
return credible
}
return lowest
}

View File

@@ -0,0 +1,140 @@
package erasure_coding
import (
"context"
"fmt"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsStubReplica(t *testing.T) {
assert.True(t, isStubReplica(0), "0-byte .dat is a stub")
assert.True(t, isStubReplica(uint64(super_block.SuperBlockSize)-1), "below a superblock is a stub")
assert.True(t, isStubReplica(uint64(super_block.SuperBlockSize)), "a bare superblock holds no data — a stub")
assert.False(t, isStubReplica(uint64(super_block.SuperBlockSize)+1), "data beyond the superblock is a real replica")
assert.False(t, isStubReplica(200*1024*1024), "a data-bearing replica is not a stub")
}
// A 0-byte stub left by an interrupted encode often sorts to a lower server id
// than the real replica. The old lowest-server canonical pick then reported
// Size=0, tripped the min-size gate, and the volume was stranded in
// skippedTooSmall forever. selectCanonicalMetric must skip the stub and return
// the data-bearing replica.
func TestSelectCanonicalMetricPrefersCredibleOverLowServerStub(t *testing.T) {
stub := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.1:8080", Size: 0}
real := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.4:8080", Size: 200 * 1024 * 1024}
got := selectCanonicalMetric([]*types.VolumeHealthMetrics{stub, real})
require.Same(t, real, got)
}
// An EC-side metric (the partial shards from a failed encode) can also sort
// below the regular replica. Picking it would short-circuit at the
// IsECVolume guard and skip the volume, hiding it from both the orphan-source
// cleanup and the re-encode path. The credible regular replica must win.
func TestSelectCanonicalMetricSkipsECMetrics(t *testing.T) {
ecMetric := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.1:8080", Size: 50 * 1024 * 1024, IsECVolume: true}
real := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.4:8080", Size: 200 * 1024 * 1024}
got := selectCanonicalMetric([]*types.VolumeHealthMetrics{ecMetric, real})
require.Same(t, real, got)
}
// When nothing is credible there is nothing to encode. Fall back to the
// lowest-server metric so the downstream gates (min-size / IsECVolume) make
// the skip decision exactly as before — selectCanonicalMetric must not invent
// a source.
func TestSelectCanonicalMetricAllStubsFallsBackToLowestServer(t *testing.T) {
stubHi := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.4:8080", Size: 0}
stubLo := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.1:8080", Size: 0}
got := selectCanonicalMetric([]*types.VolumeHealthMetrics{stubHi, stubLo})
require.Same(t, stubLo, got)
}
// Among several credible replicas the lowest server id still wins, preserving
// the deterministic canonical choice the task-dedup logic relies on.
func TestSelectCanonicalMetricTieBreaksByServerAmongCredible(t *testing.T) {
hi := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.7:8080", Size: 200 * 1024 * 1024}
lo := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.2:8080", Size: 200 * 1024 * 1024}
got := selectCanonicalMetric([]*types.VolumeHealthMetrics{hi, lo})
require.Same(t, lo, got)
}
func TestSelectCanonicalMetricEmpty(t *testing.T) {
require.Nil(t, selectCanonicalMetric(nil))
require.Nil(t, selectCanonicalMetric([]*types.VolumeHealthMetrics{}))
}
// End-to-end regression for the stranded-volume bug: a volume whose lowest
// server holds a 0-byte stub and whose real replica is on a higher server must
// still be proposed for EC encoding, not silently dropped as too-small.
func TestDetectionEncodesDespiteLowServerStub(t *testing.T) {
const volumeID uint32 = 13
activeTopology := buildStubReplicaTopology(t, volumeID)
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
lastModified := time.Now().Add(-2 * time.Hour)
metrics := []*types.VolumeHealthMetrics{
// Stub on the lowest server: an interrupted encode left a 0-byte .dat.
{VolumeID: volumeID, Server: "127.0.0.1:8080", Size: 0, FullnessRatio: 0, LastModified: lastModified, Age: time.Since(lastModified)},
// The real replica on a higher server.
{VolumeID: volumeID, Server: "127.0.0.1:8081", Size: 200 * 1024 * 1024, FullnessRatio: 0.96, LastModified: lastModified, Age: time.Since(lastModified)},
}
results, _, err := Detection(context.Background(), metrics, clusterInfo, NewDefaultConfig(), 0)
require.NoError(t, err)
require.Len(t, results, 1, "volume with a real replica must be proposed for EC despite a low-server stub")
assert.Equal(t, types.TaskTypeErasureCoding, results[0].TaskType)
assert.Equal(t, volumeID, results[0].VolumeID)
}
// buildStubReplicaTopology builds a cluster with TotalShardsCount single-disk
// nodes (enough targets to place every shard) where node 0 holds a 0-byte stub
// replica of volumeID and node 1 holds the real replica.
func buildStubReplicaTopology(t *testing.T, volumeID uint32) *topology.ActiveTopology {
t.Helper()
activeTopology := topology.NewActiveTopology(10)
nodes := make([]*master_pb.DataNodeInfo, 0, erasure_coding.TotalShardsCount)
for i := 0; i < erasure_coding.TotalShardsCount; i++ {
diskInfo := &master_pb.DiskInfo{
DiskId: 0,
MaxVolumeCount: 200,
}
switch i {
case 0:
diskInfo.VolumeInfos = []*master_pb.VolumeInformationMessage{{
Id: volumeID, DiskId: 0, DiskType: "hdd", Size: 0,
}}
diskInfo.VolumeCount = 1
case 1:
diskInfo.VolumeInfos = []*master_pb.VolumeInformationMessage{{
Id: volumeID, DiskId: 0, DiskType: "hdd", Size: 200 * 1024 * 1024,
}}
diskInfo.VolumeCount = 1
}
nodes = append(nodes, &master_pb.DataNodeInfo{
Id: fmt.Sprintf("127.0.0.1:%d", 8080+i),
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": diskInfo},
})
}
require.NoError(t, activeTopology.UpdateTopology(&master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{
Id: "rack1",
DataNodeInfos: nodes,
}},
}},
}))
return activeTopology
}

View File

@@ -0,0 +1,878 @@
package erasure_coding
import (
"context"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
ecstorage "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
func init() {
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
JobType: "erasure_coding",
Category: pluginworker.CategoryHeavy,
Aliases: []string{"erasure-coding", "erasure.coding", "ec"},
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
return NewErasureCodingHandler(opts.GrpcDialOption, opts.WorkingDir), nil
},
})
}
type erasureCodingWorkerConfig struct {
TaskConfig *Config
}
// ErasureCodingHandler is the plugin job handler for erasure coding.
type ErasureCodingHandler struct {
grpcDialOption grpc.DialOption
workingDir string
}
func NewErasureCodingHandler(grpcDialOption grpc.DialOption, workingDir string) *ErasureCodingHandler {
return &ErasureCodingHandler{grpcDialOption: grpcDialOption, workingDir: strings.TrimSpace(workingDir)}
}
func (h *ErasureCodingHandler) Capability() *plugin_pb.JobTypeCapability {
return &plugin_pb.JobTypeCapability{
JobType: "erasure_coding",
CanDetect: true,
CanExecute: true,
MaxDetectionConcurrency: 1,
MaxExecutionConcurrency: 1,
DisplayName: "EC Encoding",
Description: "Converts full and quiet volumes into EC shards",
Weight: 80,
}
}
func (h *ErasureCodingHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
return &plugin_pb.JobTypeDescriptor{
JobType: "erasure_coding",
DisplayName: "EC Encoding",
Description: "Detect and execute erasure coding for suitable volumes",
Icon: "fas fa-shield-alt",
DescriptorVersion: 1,
AdminConfigForm: &plugin_pb.ConfigForm{
FormId: "erasure-coding-admin",
Title: "Erasure Coding Admin Config",
Description: "Admin-side controls for erasure coding detection scope.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "scope",
Title: "Scope",
Description: "Optional filters applied before erasure coding detection.",
Fields: []*plugin_pb.ConfigField{
{
Name: "collection_filter",
Label: "Collection Filter",
Description: "Only erasure code volumes in matching collections. Comma-separated list of names, wildcards, or regex patterns.",
Placeholder: "all collections",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
},
},
},
DefaultValues: map[string]*plugin_pb.ConfigValue{
"collection_filter": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
},
},
},
WorkerConfigForm: &plugin_pb.ConfigForm{
FormId: "erasure-coding-worker",
Title: "Erasure Coding Worker Config",
Description: "Worker-side detection thresholds.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "thresholds",
Title: "Detection Thresholds",
Description: "Controls for when erasure coding jobs should be proposed.",
Fields: []*plugin_pb.ConfigField{
{
Name: "quiet_for_seconds",
Label: "Quiet Period (s)",
Description: "Volume must remain unmodified for at least this duration.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
Required: true,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
},
{
Name: "fullness_ratio",
Label: "Fullness Ratio",
Description: "Minimum volume fullness ratio to trigger erasure coding.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_DOUBLE,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
Required: true,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0}},
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 1}},
},
{
Name: "min_size_mb",
Label: "Minimum Volume Size (MB)",
Description: "Only volumes larger than this size are considered.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
Required: true,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
},
{
Name: "preferred_tags",
Label: "Preferred Tags",
Description: "Comma-separated disk tags to prioritize for EC shard placement, ordered by preference.",
Placeholder: "fast,ssd",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "replica_placement",
Label: "Replica Placement",
Description: "EC shard placement (e.g. 020): 2nd/3rd digits cap shards per rack/node (best-effort during encode, enforced by rebalancing); the data-center digit is ignored. Empty uses the master default.",
Placeholder: "020",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
},
},
},
DefaultValues: map[string]*plugin_pb.ConfigValue{
"quiet_for_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3600},
},
"fullness_ratio": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.95},
},
"min_size_mb": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 30},
},
"preferred_tags": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
},
"replica_placement": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
},
},
},
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
Enabled: true,
DetectionIntervalMinutes: 17,
DetectionTimeoutSeconds: 300,
MaxJobsPerDetection: 500,
GlobalExecutionConcurrency: 16,
PerWorkerExecutionConcurrency: 4,
RetryLimit: 1,
RetryBackoffSeconds: 30,
JobTypeMaxRuntimeSeconds: 1800,
ExecutionTimeoutSeconds: 1800,
},
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
"quiet_for_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3600},
},
"fullness_ratio": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.95},
},
"min_size_mb": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 30},
},
"preferred_tags": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
},
},
}
}
func (h *ErasureCodingHandler) 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 != "erasure_coding" {
return fmt.Errorf("job type %q is not handled by erasure_coding worker", request.JobType)
}
workerConfig := deriveErasureCodingWorkerConfig(request.GetWorkerConfigValues())
collectionFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "collection_filter", ""))
if collectionFilter != "" {
workerConfig.TaskConfig.CollectionFilter = collectionFilter
}
masters := make([]string, 0)
if request.ClusterContext != nil {
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
}
metrics, activeTopology, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
if err != nil {
return err
}
clusterInfo := &workertypes.ClusterInfo{
ActiveTopology: activeTopology,
GrpcDialOption: h.grpcDialOption,
DefaultReplicaPlacement: pluginworker.FetchDefaultReplicaPlacement(ctx, masters, h.grpcDialOption),
}
maxResults := int(request.MaxResults)
if maxResults < 0 {
maxResults = 0
}
results, hasMore, err := Detection(ctx, metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
if err != nil {
return err
}
// Stamp the admin-issued encode generation onto every EC proposal. DetectionSequence
// is minted once per cycle on the single admin clock, so generations are globally
// ordered even though detection runs on a rotating worker; this lets a stale worker's
// shard cleanup fence against a newer run instead of wiping it.
for _, result := range results {
if result == nil || result.TypedParams == nil {
continue
}
if ecp := result.TypedParams.GetErasureCodingParams(); ecp != nil {
ecp.EncodeTsNs = request.DetectionSequence
}
}
if traceErr := emitErasureCodingDetectionDecisionTrace(sender, metrics, workerConfig.TaskConfig, results, maxResults, hasMore); traceErr != nil {
glog.Warningf("Plugin worker failed to emit erasure_coding detection trace: %v", traceErr)
}
proposals := make([]*plugin_pb.JobProposal, 0, len(results))
for _, result := range results {
proposal, proposalErr := buildErasureCodingProposal(result, h.workingDir)
if proposalErr != nil {
glog.Warningf("Plugin worker skip invalid erasure_coding proposal: %v", proposalErr)
continue
}
proposals = append(proposals, proposal)
}
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
JobType: "erasure_coding",
Proposals: proposals,
HasMore: hasMore,
}); err != nil {
return err
}
return sender.SendComplete(&plugin_pb.DetectionComplete{
JobType: "erasure_coding",
Success: true,
TotalProposals: int32(len(proposals)),
})
}
func emitErasureCodingDetectionDecisionTrace(
sender pluginworker.DetectionSender,
metrics []*workertypes.VolumeHealthMetrics,
taskConfig *Config,
results []*workertypes.TaskDetectionResult,
maxResults int,
hasMore bool,
) error {
if sender == nil || taskConfig == nil {
return nil
}
quietThreshold := time.Duration(taskConfig.QuietForSeconds) * time.Second
minSizeBytes := uint64(taskConfig.MinSizeMB) * 1024 * 1024
allowedCollections, err := wildcard.CompileCollectionMatcher(taskConfig.CollectionFilter)
if err != nil {
return err
}
volumeGroups := make(map[uint32][]*workertypes.VolumeHealthMetrics)
for _, metric := range metrics {
if metric == nil {
continue
}
volumeGroups[metric.VolumeID] = append(volumeGroups[metric.VolumeID], metric)
}
skippedAlreadyEC := 0
skippedTooSmall := 0
skippedCollectionFilter := 0
skippedQuietTime := 0
skippedFullness := 0
for _, groupMetrics := range volumeGroups {
if len(groupMetrics) == 0 {
continue
}
metric := groupMetrics[0]
for _, candidate := range groupMetrics {
if candidate != nil && candidate.Server < metric.Server {
metric = candidate
}
}
if metric == nil {
continue
}
if metric.IsECVolume {
skippedAlreadyEC++
continue
}
if metric.Size < minSizeBytes {
skippedTooSmall++
continue
}
if !allowedCollections.Matches(metric.Collection) {
skippedCollectionFilter++
continue
}
if metric.Age < quietThreshold {
skippedQuietTime++
continue
}
if metric.FullnessRatio < taskConfig.FullnessRatio {
skippedFullness++
continue
}
}
totalVolumes := len(metrics)
summarySuffix := ""
if hasMore {
summarySuffix = fmt.Sprintf(" (max_results=%d reached; remaining volumes not evaluated)", maxResults)
}
summaryMessage := ""
if len(results) == 0 {
summaryMessage = fmt.Sprintf(
"EC detection: No tasks created for %d volumes%s (skipped: %d already EC, %d too small, %d filtered, %d not quiet, %d not full)",
totalVolumes,
summarySuffix,
skippedAlreadyEC,
skippedTooSmall,
skippedCollectionFilter,
skippedQuietTime,
skippedFullness,
)
} else {
summaryMessage = fmt.Sprintf(
"EC detection: Created %d task(s)%s from %d volumes (skipped: %d already EC, %d too small, %d filtered, %d not quiet, %d not full)",
len(results),
summarySuffix,
totalVolumes,
skippedAlreadyEC,
skippedTooSmall,
skippedCollectionFilter,
skippedQuietTime,
skippedFullness,
)
}
if err := sender.SendActivity(pluginworker.BuildDetectorActivity("decision_summary", summaryMessage, map[string]*plugin_pb.ConfigValue{
"total_volumes": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(totalVolumes)},
},
"selected_tasks": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(results))},
},
"max_results": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(maxResults)},
},
"has_more": {
Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: hasMore},
},
"skipped_already_ec": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedAlreadyEC)},
},
"skipped_too_small": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedTooSmall)},
},
"skipped_filtered": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedCollectionFilter)},
},
"skipped_not_quiet": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedQuietTime)},
},
"skipped_not_full": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedFullness)},
},
"quiet_for_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.QuietForSeconds)},
},
"min_size_mb": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.MinSizeMB)},
},
"fullness_threshold_percent": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: taskConfig.FullnessRatio * 100},
},
})); err != nil {
return err
}
detailsEmitted := 0
for _, metric := range metrics {
if metric == nil || metric.IsECVolume {
continue
}
sizeMB := float64(metric.Size) / (1024 * 1024)
message := fmt.Sprintf(
"ERASURE CODING: Volume %d: size=%.1fMB (need ≥%dMB), age=%s (need ≥%s), fullness=%.1f%% (need ≥%.1f%%)",
metric.VolumeID,
sizeMB,
taskConfig.MinSizeMB,
metric.Age.Truncate(time.Minute),
quietThreshold.Truncate(time.Minute),
metric.FullnessRatio*100,
taskConfig.FullnessRatio*100,
)
if err := sender.SendActivity(pluginworker.BuildDetectorActivity("decision_volume", message, map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(metric.VolumeID)},
},
"size_mb": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: sizeMB},
},
"required_min_size_mb": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.MinSizeMB)},
},
"age_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(metric.Age.Seconds())},
},
"required_quiet_for_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.QuietForSeconds)},
},
"fullness_percent": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: metric.FullnessRatio * 100},
},
"required_fullness_percent": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: taskConfig.FullnessRatio * 100},
},
})); err != nil {
return err
}
detailsEmitted++
if detailsEmitted >= 3 {
break
}
}
return nil
}
func (h *ErasureCodingHandler) 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 != "erasure_coding" {
return fmt.Errorf("job type %q is not handled by erasure_coding worker", request.Job.JobType)
}
params, err := decodeErasureCodingTaskParams(request.Job)
if err != nil {
return err
}
applyErasureCodingExecutionDefaults(params, request.GetClusterContext(), h.workingDir)
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
return fmt.Errorf("erasure coding source node is required")
}
if len(params.Targets) == 0 {
return fmt.Errorf("erasure coding targets are required")
}
task := NewErasureCodingTask(
request.Job.JobId,
params.Sources[0].Node,
params.VolumeId,
params.Collection,
h.grpcDialOption,
)
execCtx, execCancel := context.WithCancel(ctx)
defer execCancel()
task.SetProgressCallback(func(progress float64, stage string) {
message := fmt.Sprintf("erasure coding progress %.0f%%", progress)
if strings.TrimSpace(stage) != "" {
message = stage
}
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
State: plugin_pb.JobState_JOB_STATE_RUNNING,
ProgressPercent: progress,
Stage: stage,
Message: message,
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity(stage, message),
},
}); err != nil {
execCancel()
}
})
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
ProgressPercent: 0,
Stage: "assigned",
Message: "erasure coding job accepted",
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("assigned", "erasure coding job accepted"),
},
}); err != nil {
return err
}
if err := task.Execute(execCtx, params); err != nil {
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
State: plugin_pb.JobState_JOB_STATE_FAILED,
ProgressPercent: 100,
Stage: "failed",
Message: err.Error(),
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("failed", err.Error()),
},
})
return err
}
sourceNode := params.Sources[0].Node
resultSummary := fmt.Sprintf("erasure coding completed for volume %d across %d targets", params.VolumeId, len(params.Targets))
return sender.SendCompleted(&plugin_pb.JobCompleted{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
Success: true,
Result: &plugin_pb.JobResult{
Summary: resultSummary,
OutputValues: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(params.VolumeId)},
},
"source_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
},
"target_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(params.Targets))},
},
},
},
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("completed", resultSummary),
},
})
}
func (h *ErasureCodingHandler) collectVolumeMetrics(
ctx context.Context,
masterAddresses []string,
collectionFilter string,
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
metrics, activeTopology, _, err := pluginworker.CollectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
return metrics, activeTopology, err
}
func deriveErasureCodingWorkerConfig(values map[string]*plugin_pb.ConfigValue) *erasureCodingWorkerConfig {
taskConfig := NewDefaultConfig()
quietForSeconds := pluginworker.ReadIntConfig(values, "quiet_for_seconds", taskConfig.QuietForSeconds)
if quietForSeconds < 0 {
quietForSeconds = 0
}
taskConfig.QuietForSeconds = quietForSeconds
fullnessRatio := pluginworker.ReadDoubleConfig(values, "fullness_ratio", taskConfig.FullnessRatio)
if fullnessRatio < 0 {
fullnessRatio = 0
}
if fullnessRatio > 1 {
fullnessRatio = 1
}
taskConfig.FullnessRatio = fullnessRatio
minSizeMB := pluginworker.ReadIntConfig(values, "min_size_mb", taskConfig.MinSizeMB)
if minSizeMB < 1 {
minSizeMB = 1
}
taskConfig.MinSizeMB = minSizeMB
taskConfig.PreferredTags = util.NormalizeTagList(pluginworker.ReadStringListConfig(values, "preferred_tags"))
taskConfig.ReplicaPlacement = strings.TrimSpace(pluginworker.ReadStringConfig(values, "replica_placement", taskConfig.ReplicaPlacement))
return &erasureCodingWorkerConfig{
TaskConfig: taskConfig,
}
}
func buildErasureCodingProposal(
result *workertypes.TaskDetectionResult,
baseWorkingDir string,
) (*plugin_pb.JobProposal, error) {
if result == nil {
return nil, fmt.Errorf("task detection result is nil")
}
if result.TypedParams == nil {
return nil, fmt.Errorf("missing typed params for volume %d", result.VolumeID)
}
params := proto.Clone(result.TypedParams).(*worker_pb.TaskParams)
applyErasureCodingExecutionDefaults(params, nil, baseWorkingDir)
paramsPayload, err := proto.Marshal(params)
if err != nil {
return nil, fmt.Errorf("marshal task params: %w", err)
}
proposalID := strings.TrimSpace(result.TaskID)
if proposalID == "" {
proposalID = fmt.Sprintf("erasure-coding-%d-%d", result.VolumeID, time.Now().UnixNano())
}
dedupeKey := fmt.Sprintf("erasure_coding:%d", result.VolumeID)
if result.Collection != "" {
dedupeKey += ":" + result.Collection
}
sourceNode := ""
if len(params.Sources) > 0 {
sourceNode = strings.TrimSpace(params.Sources[0].Node)
}
summary := fmt.Sprintf("Erasure code volume %d", result.VolumeID)
if sourceNode != "" {
summary = fmt.Sprintf("Erasure code volume %d from %s", result.VolumeID, sourceNode)
}
// EC encoding reads the full volume, computes shards, and writes 14
// shards out to target nodes. Budget 10 min/GB (roughly 2x a plain copy)
// so the scheduler grants a deadline scaled to volume size.
volumeSizeGB := int64(result.TypedParams.VolumeSize/1024/1024/1024) + 1
estimatedRuntimeSeconds := volumeSizeGB * 10 * 60
return &plugin_pb.JobProposal{
ProposalId: proposalID,
DedupeKey: dedupeKey,
JobType: "erasure_coding",
Priority: pluginworker.MapTaskPriority(result.Priority),
Summary: summary,
Detail: strings.TrimSpace(result.Reason),
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {
Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: paramsPayload},
},
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(result.VolumeID)},
},
"source_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Collection},
},
"target_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(params.Targets))},
},
"estimated_runtime_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: estimatedRuntimeSeconds},
},
},
Labels: map[string]string{
"task_type": "erasure_coding",
"volume_id": fmt.Sprintf("%d", result.VolumeID),
"collection": result.Collection,
"source_node": sourceNode,
"target_count": fmt.Sprintf("%d", len(params.Targets)),
},
}, nil
}
func decodeErasureCodingTaskParams(job *plugin_pb.JobSpec) (*worker_pb.TaskParams, error) {
if job == nil {
return nil, fmt.Errorf("job spec is nil")
}
if payload := pluginworker.ReadBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
params := &worker_pb.TaskParams{}
if err := proto.Unmarshal(payload, params); err != nil {
return nil, fmt.Errorf("unmarshal task_params_pb: %w", err)
}
if params.TaskId == "" {
params.TaskId = job.JobId
}
return params, nil
}
volumeID := pluginworker.ReadUint32Config(job.Parameters, "volume_id", 0)
sourceNode := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "source_server", ""))
if sourceNode == "" {
sourceNode = strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "server", ""))
}
targetServers := pluginworker.ReadStringListConfig(job.Parameters, "target_servers")
if len(targetServers) == 0 {
targetServers = pluginworker.ReadStringListConfig(job.Parameters, "targets")
}
collection := pluginworker.ReadStringConfig(job.Parameters, "collection", "")
dataShards := pluginworker.ReadInt32Config(job.Parameters, "data_shards", int32(ecstorage.DataShardsCount))
if dataShards <= 0 {
dataShards = int32(ecstorage.DataShardsCount)
}
parityShards := pluginworker.ReadInt32Config(job.Parameters, "parity_shards", int32(ecstorage.ParityShardsCount))
if parityShards <= 0 {
parityShards = int32(ecstorage.ParityShardsCount)
}
sourceDiskType := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "source_disk_type", ""))
totalShards := int(dataShards + parityShards)
if volumeID == 0 {
return nil, fmt.Errorf("missing volume_id in job parameters")
}
if sourceNode == "" {
return nil, fmt.Errorf("missing source_server in job parameters")
}
if len(targetServers) == 0 {
return nil, fmt.Errorf("missing target_servers in job parameters")
}
if len(targetServers) < totalShards {
return nil, fmt.Errorf("insufficient target_servers: got %d, need at least %d", len(targetServers), totalShards)
}
shardAssignments := assignECShardIDs(totalShards, len(targetServers))
targets := make([]*worker_pb.TaskTarget, 0, len(targetServers))
for i := 0; i < len(targetServers); i++ {
targetNode := strings.TrimSpace(targetServers[i])
if targetNode == "" {
continue
}
targets = append(targets, &worker_pb.TaskTarget{
Node: targetNode,
VolumeId: volumeID,
ShardIds: shardAssignments[i],
})
}
if len(targets) < totalShards {
return nil, fmt.Errorf("insufficient non-empty target_servers after normalization: got %d, need at least %d", len(targets), totalShards)
}
return &worker_pb.TaskParams{
TaskId: job.JobId,
VolumeId: volumeID,
Collection: collection,
Sources: []*worker_pb.TaskSource{
{
Node: sourceNode,
VolumeId: volumeID,
},
},
Targets: targets,
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
DataShards: dataShards,
ParityShards: parityShards,
SourceDiskType: sourceDiskType,
},
},
}, nil
}
func applyErasureCodingExecutionDefaults(
params *worker_pb.TaskParams,
clusterContext *plugin_pb.ClusterContext,
baseWorkingDir string,
) {
if params == nil {
return
}
ecParams := params.GetErasureCodingParams()
if ecParams == nil {
ecParams = &worker_pb.ErasureCodingTaskParams{
DataShards: ecstorage.DataShardsCount,
ParityShards: ecstorage.ParityShardsCount,
}
params.TaskParams = &worker_pb.TaskParams_ErasureCodingParams{ErasureCodingParams: ecParams}
}
if ecParams.DataShards <= 0 {
ecParams.DataShards = ecstorage.DataShardsCount
}
if ecParams.ParityShards <= 0 {
ecParams.ParityShards = ecstorage.ParityShardsCount
}
ecParams.WorkingDir = defaultErasureCodingWorkingDir(baseWorkingDir)
ecParams.CleanupSource = true
if strings.TrimSpace(ecParams.MasterClient) == "" && clusterContext != nil && len(clusterContext.MasterGrpcAddresses) > 0 {
ecParams.MasterClient = clusterContext.MasterGrpcAddresses[0]
}
totalShards := int(ecParams.DataShards + ecParams.ParityShards)
if totalShards <= 0 {
totalShards = ecstorage.TotalShardsCount
}
needsShardAssignment := false
for _, target := range params.Targets {
if target == nil || len(target.ShardIds) == 0 {
needsShardAssignment = true
break
}
}
if needsShardAssignment && len(params.Targets) > 0 {
assignments := assignECShardIDs(totalShards, len(params.Targets))
for i := 0; i < len(params.Targets); i++ {
if params.Targets[i] == nil {
continue
}
if len(params.Targets[i].ShardIds) == 0 {
params.Targets[i].ShardIds = assignments[i]
}
}
}
}
func assignECShardIDs(totalShards int, targetCount int) [][]uint32 {
if targetCount <= 0 {
return nil
}
if totalShards <= 0 {
totalShards = ecstorage.TotalShardsCount
}
assignments := make([][]uint32, targetCount)
for i := 0; i < totalShards; i++ {
targetIndex := i % targetCount
assignments[targetIndex] = append(assignments[targetIndex], uint32(i))
}
return assignments
}
func defaultErasureCodingWorkingDir(baseWorkingDir string) string {
dir := strings.TrimSpace(baseWorkingDir)
if dir == "" {
return filepath.Join(".", "erasure_coding")
}
return filepath.Join(dir, "erasure_coding")
}

View File

@@ -0,0 +1,343 @@
package erasure_coding
import (
"context"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
ecstorage "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
"google.golang.org/protobuf/proto"
)
func TestDecodeErasureCodingTaskParamsFromPayload(t *testing.T) {
expected := &worker_pb.TaskParams{
TaskId: "task-ec-1",
VolumeId: 88,
Collection: "images",
Sources: []*worker_pb.TaskSource{
{
Node: "10.0.0.1:8080",
VolumeId: 88,
},
},
Targets: []*worker_pb.TaskTarget{
{
Node: "10.0.0.2:8080",
VolumeId: 88,
ShardIds: []uint32{0, 10},
},
},
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
DataShards: ecstorage.DataShardsCount,
ParityShards: ecstorage.ParityShardsCount,
WorkingDir: "/tmp/ec-work",
CleanupSource: true,
},
},
}
payload, err := proto.Marshal(expected)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
job := &plugin_pb.JobSpec{
JobId: "job-from-admin",
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
},
}
actual, err := decodeErasureCodingTaskParams(job)
if err != nil {
t.Fatalf("decodeErasureCodingTaskParams() err = %v", err)
}
if !proto.Equal(expected, actual) {
t.Fatalf("decoded params mismatch\nexpected: %+v\nactual: %+v", expected, actual)
}
}
func TestDecodeErasureCodingTaskParamsFallback(t *testing.T) {
targetServers := make([]string, 0, ecstorage.TotalShardsCount)
for i := 0; i < ecstorage.TotalShardsCount; i++ {
targetServers = append(targetServers, "10.0.0."+string(rune('a'+i))+":8080")
}
job := &plugin_pb.JobSpec{
JobId: "job-ec-2",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7},
},
"source_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "127.0.0.1:8080"},
},
"target_servers": {
Kind: &plugin_pb.ConfigValue_StringList{
StringList: &plugin_pb.StringList{Values: targetServers},
},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "videos"},
},
},
}
params, err := decodeErasureCodingTaskParams(job)
if err != nil {
t.Fatalf("decodeErasureCodingTaskParams() err = %v", err)
}
if params.TaskId != "job-ec-2" || params.VolumeId != 7 || params.Collection != "videos" {
t.Fatalf("unexpected basic params: %+v", params)
}
if len(params.Sources) != 1 || params.Sources[0].Node != "127.0.0.1:8080" {
t.Fatalf("unexpected sources: %+v", params.Sources)
}
if len(params.Targets) != ecstorage.TotalShardsCount {
t.Fatalf("unexpected target count: %d", len(params.Targets))
}
if params.GetErasureCodingParams() == nil {
t.Fatalf("expected fallback erasure coding params")
}
}
func TestDeriveErasureCodingWorkerConfig(t *testing.T) {
values := map[string]*plugin_pb.ConfigValue{
"quiet_for_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 720},
},
"fullness_ratio": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.92},
},
"min_size_mb": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 128},
},
}
cfg := deriveErasureCodingWorkerConfig(values)
if cfg.TaskConfig.QuietForSeconds != 720 {
t.Fatalf("expected quiet_for_seconds 720, got %d", cfg.TaskConfig.QuietForSeconds)
}
if cfg.TaskConfig.FullnessRatio != 0.92 {
t.Fatalf("expected fullness_ratio 0.92, got %v", cfg.TaskConfig.FullnessRatio)
}
if cfg.TaskConfig.MinSizeMB != 128 {
t.Fatalf("expected min_size_mb 128, got %d", cfg.TaskConfig.MinSizeMB)
}
}
func TestBuildErasureCodingProposal(t *testing.T) {
params := &worker_pb.TaskParams{
TaskId: "ec-task-1",
VolumeId: 99,
Collection: "c1",
Sources: []*worker_pb.TaskSource{
{
Node: "source-a:8080",
VolumeId: 99,
},
},
Targets: []*worker_pb.TaskTarget{
{
Node: "target-a:8080",
VolumeId: 99,
ShardIds: []uint32{0, 10},
},
{
Node: "target-b:8080",
VolumeId: 99,
ShardIds: []uint32{1, 11},
},
},
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
DataShards: ecstorage.DataShardsCount,
ParityShards: ecstorage.ParityShardsCount,
},
},
}
result := &workertypes.TaskDetectionResult{
TaskID: "ec-task-1",
TaskType: workertypes.TaskTypeErasureCoding,
VolumeID: 99,
Server: "source-a",
Collection: "c1",
Priority: workertypes.TaskPriorityLow,
Reason: "quiet and full",
TypedParams: params,
}
proposal, err := buildErasureCodingProposal(result, "")
if err != nil {
t.Fatalf("buildErasureCodingProposal() err = %v", err)
}
if proposal.JobType != "erasure_coding" {
t.Fatalf("unexpected job type %q", proposal.JobType)
}
if proposal.Parameters["task_params_pb"] == nil {
t.Fatalf("expected serialized task params")
}
if proposal.Labels["source_node"] != "source-a:8080" {
t.Fatalf("unexpected source label %q", proposal.Labels["source_node"])
}
}
func TestErasureCodingHandlerRejectsUnsupportedJobType(t *testing.T) {
handler := NewErasureCodingHandler(nil, "")
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: "vacuum",
}, noopDetectionSender{})
if err == nil {
t.Fatalf("expected detect job type mismatch error")
}
err = handler.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
Job: &plugin_pb.JobSpec{JobId: "job-1", JobType: "vacuum"},
}, noopExecutionSender{})
if err == nil {
t.Fatalf("expected execute job type mismatch error")
}
}
func TestEmitErasureCodingDetectionDecisionTraceNoTasks(t *testing.T) {
sender := &recordingDetectionSender{}
config := NewDefaultConfig()
config.QuietForSeconds = 5 * 60
config.MinSizeMB = 30
config.FullnessRatio = 0.91
metrics := []*workertypes.VolumeHealthMetrics{
{
VolumeID: 20,
Size: 0,
Age: 218*time.Hour + 41*time.Minute,
FullnessRatio: 0,
},
{
VolumeID: 27,
Size: uint64(16 * 1024 * 1024 / 10),
Age: 91*time.Hour + time.Minute,
FullnessRatio: 0.002,
},
{
VolumeID: 12,
Size: 0,
Age: 219*time.Hour + 49*time.Minute,
FullnessRatio: 0,
},
}
if err := emitErasureCodingDetectionDecisionTrace(sender, metrics, config, nil, 0, false); err != nil {
t.Fatalf("emitErasureCodingDetectionDecisionTrace error: %v", err)
}
if len(sender.events) < 4 {
t.Fatalf("expected at least 4 detection events, got %d", len(sender.events))
}
if sender.events[0].Source != plugin_pb.ActivitySource_ACTIVITY_SOURCE_DETECTOR {
t.Fatalf("expected detector source, got %v", sender.events[0].Source)
}
if !strings.Contains(sender.events[0].Message, "EC detection: No tasks created for 3 volumes") {
t.Fatalf("unexpected summary message: %q", sender.events[0].Message)
}
if !strings.Contains(sender.events[1].Message, "ERASURE CODING: Volume 20: size=0.0MB") {
t.Fatalf("unexpected first detail message: %q", sender.events[1].Message)
}
}
func TestErasureCodingDescriptorOmitsLocalExecutionFields(t *testing.T) {
descriptor := NewErasureCodingHandler(nil, "").Descriptor()
if descriptor == nil || descriptor.WorkerConfigForm == nil {
t.Fatalf("expected worker config form in descriptor")
}
if workerConfigFormHasField(descriptor.WorkerConfigForm, "working_dir") {
t.Fatalf("unexpected working_dir in erasure coding worker config form")
}
if workerConfigFormHasField(descriptor.WorkerConfigForm, "cleanup_source") {
t.Fatalf("unexpected cleanup_source in erasure coding worker config form")
}
}
func TestApplyErasureCodingExecutionDefaultsForcesLocalFields(t *testing.T) {
baseWorkingDir := "/var/lib/seaweedfs-worker"
params := &worker_pb.TaskParams{
TaskId: "ec-test",
VolumeId: 100,
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
DataShards: ecstorage.DataShardsCount,
ParityShards: ecstorage.ParityShardsCount,
WorkingDir: "/tmp/custom-from-job",
CleanupSource: false,
},
},
}
applyErasureCodingExecutionDefaults(params, nil, baseWorkingDir)
ecParams := params.GetErasureCodingParams()
if ecParams == nil {
t.Fatalf("expected erasure coding params")
}
if ecParams.WorkingDir != defaultErasureCodingWorkingDir(baseWorkingDir) {
t.Fatalf("expected local working_dir %q, got %q", defaultErasureCodingWorkingDir(baseWorkingDir), ecParams.WorkingDir)
}
if !ecParams.CleanupSource {
t.Fatalf("expected cleanup_source true")
}
}
type noopDetectionSender struct{}
func (noopDetectionSender) SendProposals(*plugin_pb.DetectionProposals) error { return nil }
func (noopDetectionSender) SendComplete(*plugin_pb.DetectionComplete) error { return nil }
func (noopDetectionSender) SendActivity(*plugin_pb.ActivityEvent) error { return nil }
type noopExecutionSender struct{}
func (noopExecutionSender) SendProgress(*plugin_pb.JobProgressUpdate) error { return nil }
func (noopExecutionSender) SendCompleted(*plugin_pb.JobCompleted) error { return nil }
type recordingDetectionSender struct {
proposals *plugin_pb.DetectionProposals
complete *plugin_pb.DetectionComplete
events []*plugin_pb.ActivityEvent
}
func (r *recordingDetectionSender) SendProposals(proposals *plugin_pb.DetectionProposals) error {
r.proposals = proposals
return nil
}
func (r *recordingDetectionSender) SendComplete(complete *plugin_pb.DetectionComplete) error {
r.complete = complete
return nil
}
func (r *recordingDetectionSender) SendActivity(event *plugin_pb.ActivityEvent) error {
if event != nil {
r.events = append(r.events, event)
}
return nil
}
func workerConfigFormHasField(form *plugin_pb.ConfigForm, fieldName string) bool {
if form == nil {
return false
}
for _, section := range form.Sections {
if section == nil {
continue
}
for _, field := range section.Fields {
if field != nil && field.Name == fieldName {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,96 @@
package erasure_coding
import (
"context"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Global variable to hold the task definition for configuration updates
var globalTaskDef *base.TaskDefinition
// Auto-register this task when the package is imported
func init() {
RegisterErasureCodingTask()
// Register config updater
tasks.AutoRegisterConfigUpdater(types.TaskTypeErasureCoding, UpdateConfigFromPersistence)
}
// RegisterErasureCodingTask registers the erasure coding task with the new architecture
func RegisterErasureCodingTask() {
// Create configuration instance
config := NewDefaultConfig()
// Create shared gRPC dial option using TLS configuration
dialOpt := security.LoadClientTLS(util.GetViper(), "grpc.worker")
// Create complete task definition
taskDef := &base.TaskDefinition{
Type: types.TaskTypeErasureCoding,
Name: "erasure_coding",
DisplayName: "Erasure Coding",
Description: "Applies erasure coding to volumes for data protection",
Icon: "fas fa-shield-alt text-success",
Capabilities: []string{"erasure_coding", "data_protection"},
Config: config,
ConfigSpec: GetConfigSpec(),
CreateTask: func(params *worker_pb.TaskParams) (types.Task, error) {
if params == nil {
return nil, fmt.Errorf("task parameters are required")
}
if len(params.Sources) == 0 {
return nil, fmt.Errorf("at least one source is required for erasure coding task")
}
return NewErasureCodingTask(
fmt.Sprintf("erasure_coding-%d", params.VolumeId),
params.Sources[0].Node, // Use first source node
params.VolumeId,
params.Collection,
dialOpt,
), nil
},
DetectionFunc: func(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig) ([]*types.TaskDetectionResult, error) {
results, _, err := Detection(context.Background(), metrics, clusterInfo, config, 0)
return results, err
},
ScanInterval: 1 * time.Hour,
SchedulingFunc: Scheduling,
MaxConcurrent: 1,
RepeatInterval: 24 * time.Hour,
}
// Store task definition globally for configuration updates
globalTaskDef = taskDef
// Register everything with a single function call!
base.RegisterTask(taskDef)
}
// UpdateConfigFromPersistence updates the erasure coding configuration from persistence
func UpdateConfigFromPersistence(configPersistence interface{}) error {
if globalTaskDef == nil {
return fmt.Errorf("erasure coding task not registered")
}
// Load configuration from persistence
newConfig := LoadConfigFromPersistence(configPersistence)
if newConfig == nil {
return fmt.Errorf("failed to load configuration from persistence")
}
// Update the task definition's config
globalTaskDef.Config = newConfig
glog.V(1).Infof("Updated erasure coding task configuration from persistence")
return nil
}

View File

@@ -0,0 +1,40 @@
package erasure_coding
import (
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Scheduling implements the scheduling logic for erasure coding tasks
func Scheduling(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData, config base.TaskConfig) bool {
ecConfig := config.(*Config)
// Check if we have available workers
if len(availableWorkers) == 0 {
return false
}
// Count running EC tasks
runningCount := 0
for _, runningTask := range runningTasks {
if runningTask.Type == types.TaskTypeErasureCoding {
runningCount++
}
}
// Check concurrency limit
if runningCount >= ecConfig.MaxConcurrent {
return false
}
// Check if any worker can handle EC tasks
for _, worker := range availableWorkers {
for _, capability := range worker.Capabilities {
if capability == types.TaskTypeErasureCoding {
return true
}
}
}
return false
}

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)
}
}

View File

@@ -0,0 +1,103 @@
package tasks
import (
"sync"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
var (
globalTypesRegistry *types.TaskRegistry
globalUIRegistry *types.UIRegistry
globalTaskRegistry *TaskRegistry
typesRegistryOnce sync.Once
uiRegistryOnce sync.Once
taskRegistryOnce sync.Once
)
// GetGlobalTypesRegistry returns the global types registry (singleton)
func GetGlobalTypesRegistry() *types.TaskRegistry {
typesRegistryOnce.Do(func() {
globalTypesRegistry = types.NewTaskRegistry()
glog.V(1).Infof("Created global types registry")
})
return globalTypesRegistry
}
// GetGlobalUIRegistry returns the global UI registry (singleton)
func GetGlobalUIRegistry() *types.UIRegistry {
uiRegistryOnce.Do(func() {
globalUIRegistry = types.NewUIRegistry()
glog.V(1).Infof("Created global UI registry")
})
return globalUIRegistry
}
// GetGlobalTaskRegistry returns the global task registry (singleton)
func GetGlobalTaskRegistry() *TaskRegistry {
taskRegistryOnce.Do(func() {
globalTaskRegistry = NewTaskRegistry()
glog.V(1).Infof("Created global task registry")
})
return globalTaskRegistry
}
// AutoRegister registers a task with the global task registry
func AutoRegister(taskType types.TaskType, factory types.TaskFactory) {
registry := GetGlobalTaskRegistry()
registry.Register(taskType, factory)
glog.V(1).Infof("Auto-registered task type: %s", taskType)
}
// AutoRegisterTypes registers a task with the global types registry
func AutoRegisterTypes(registerFunc func(*types.TaskRegistry)) {
registry := GetGlobalTypesRegistry()
registerFunc(registry)
glog.V(1).Infof("Auto-registered task with types registry")
}
// AutoRegisterUI registers a UI provider with the global UI registry
func AutoRegisterUI(registerFunc func(*types.UIRegistry)) {
registry := GetGlobalUIRegistry()
registerFunc(registry)
glog.V(1).Infof("Auto-registered task UI provider")
}
// TaskRegistry manages task factories
type TaskRegistry struct {
factories map[types.TaskType]types.TaskFactory
mutex sync.RWMutex
}
// NewTaskRegistry creates a new task registry
func NewTaskRegistry() *TaskRegistry {
return &TaskRegistry{
factories: make(map[types.TaskType]types.TaskFactory),
}
}
// Register adds a factory to the registry
func (r *TaskRegistry) Register(taskType types.TaskType, factory types.TaskFactory) {
r.mutex.Lock()
defer r.mutex.Unlock()
r.factories[taskType] = factory
}
// Get returns a factory from the registry
func (r *TaskRegistry) Get(taskType types.TaskType) types.TaskFactory {
r.mutex.RLock()
defer r.mutex.RUnlock()
return r.factories[taskType]
}
// GetAll returns all registered factories
func (r *TaskRegistry) GetAll() map[types.TaskType]types.TaskFactory {
r.mutex.RLock()
defer r.mutex.RUnlock()
result := make(map[types.TaskType]types.TaskFactory)
for k, v := range r.factories {
result[k] = v
}
return result
}

View File

@@ -0,0 +1,54 @@
package s3_lifecycle
// Cluster-wide rate-limit configuration plumbing for the daily-replay
// worker. The admin holds a single "cluster delete budget" knob, divides
// it by the number of execute-capable s3_lifecycle workers at job-dispatch
// time, and ships the per-worker share to the worker via
// ExecuteJobRequest.ClusterContext.Metadata. The worker reads the share,
// constructs a rate.Limiter, and passes it to dailyrun.Run.
//
// These constants are the contract between admin (weed/admin/plugin/plugin.go
// computes the share and writes the keys) and worker (this package's
// handler.go reads them). Changing a name on one side without the other
// would silently disable rate limiting — both sides must read these
// exact values.
const (
// ClusterDeletesPerSecondAdminKey is the admin-config field that
// holds the cluster-wide budget in delete RPCs per second. 0 means
// unlimited (legacy behavior). Set via the AdminConfigForm in
// handler.go's "Scope" section.
ClusterDeletesPerSecondAdminKey = "cluster_deletes_per_second"
// ClusterDeletesBurstAdminKey holds the token-bucket burst. 0 means
// "2 × rps" (computed by the admin allocator).
ClusterDeletesBurstAdminKey = "cluster_deletes_burst"
// MetaLogRetentionDaysAdminKey holds the operator's declaration of
// how far back the filer's meta-log subscription can reliably reach.
// Rules whose effective TTL exceeds this window can't be serviced by
// replay alone and get partitioned into engine.PromotedHash's walk
// set; a partition flip (operator shrinks retention) then trips the
// recovery branch on the next run. 0 = unbounded (current behavior,
// falls back to maxTTL in runShard so PromotedHash stays empty).
MetaLogRetentionDaysAdminKey = "meta_log_retention_days"
// WalkerIntervalMinutesAdminKey throttles the per-shard steady-state
// and empty-replay walker fires. dailyrun.runShard checks the time
// since the persisted Cursor.LastWalkedNs and skips the walk when
// less than this interval has elapsed; cold-start and recovery walker
// fires (RecoveryView) stay unconditional. 0 means "fire on every
// run" (the prior behavior — appropriate when the worker is driven
// at the operator's intended walk cadence, e.g. once per hour).
// Production deployments running the worker at multi-second cadence
// (CI ticks, sub-minute admin schedules) should set this to roughly
// the per-shard walk budget — typically 60 (1h) for small clusters,
// 360+ (6h+) for large ones.
WalkerIntervalMinutesAdminKey = "walker_interval_minutes"
// MetadataKeyDeletesPerSecond is the per-worker share value the
// admin writes into ClusterContext.Metadata at ExecuteJob time.
// Stored as a string of a non-negative float64; empty/missing/zero
// means "no rate limit on this run" (cfg.Limiter stays nil).
MetadataKeyDeletesPerSecond = "s3_lifecycle.deletes_per_second"
// MetadataKeyDeletesBurst is the per-worker burst share. Stored as
// a string of a non-negative integer.
MetadataKeyDeletesBurst = "s3_lifecycle.deletes_burst"
)

View File

@@ -0,0 +1,78 @@
package s3_lifecycle
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildLimiterFromClusterContext_NilCC(t *testing.T) {
l, desc := buildLimiterFromClusterContext(nil)
assert.Nil(t, l)
assert.Equal(t, "unlimited", desc)
}
func TestBuildLimiterFromClusterContext_NoMetadata(t *testing.T) {
l, desc := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{})
assert.Nil(t, l)
assert.Equal(t, "unlimited", desc)
}
func TestBuildLimiterFromClusterContext_MissingRateKey(t *testing.T) {
l, desc := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{
Metadata: map[string]string{"unrelated": "x"},
})
assert.Nil(t, l)
assert.Equal(t, "unlimited", desc)
}
func TestBuildLimiterFromClusterContext_NonPositiveRate(t *testing.T) {
// 0 or negative rate means the admin didn't allocate; the worker
// must NOT construct a limiter that throttles every request to
// zero-throughput.
for _, raw := range []string{"0", "-1", "0.0", "not-a-number", ""} {
l, desc := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{
Metadata: map[string]string{MetadataKeyDeletesPerSecond: raw},
})
assert.Nil(t, l, "rate=%q must yield nil limiter", raw)
assert.Equal(t, "unlimited", desc)
}
}
func TestBuildLimiterFromClusterContext_PositiveRateBuildsLimiter(t *testing.T) {
l, desc := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{
Metadata: map[string]string{
MetadataKeyDeletesPerSecond: "12.5",
MetadataKeyDeletesBurst: "25",
},
})
require.NotNil(t, l)
assert.Equal(t, 12.5, float64(l.Limit()))
assert.Equal(t, 25, l.Burst())
assert.Contains(t, desc, "12.5/s")
assert.Contains(t, desc, "burst=25")
}
func TestBuildLimiterFromClusterContext_BurstMissingDefaultsTo2xRate(t *testing.T) {
// burst omitted (admin allocator wrote nothing) → worker computes
// 2 × rps so a single tick has headroom for two refills.
l, _ := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{
Metadata: map[string]string{MetadataKeyDeletesPerSecond: "10"},
})
require.NotNil(t, l)
assert.Equal(t, 10.0, float64(l.Limit()))
assert.Equal(t, 20, l.Burst(), "burst default must be 2× rps")
}
func TestBuildLimiterFromClusterContext_TinyRateClampsBurstToOne(t *testing.T) {
// A sub-1-rps allocation (e.g. 0.5/s across few workers) would
// compute 2 × 0.5 = 1, but if the int truncation produced 0 the
// limiter would never refill. Clamp to at least 1.
l, _ := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{
Metadata: map[string]string{MetadataKeyDeletesPerSecond: "0.1"},
})
require.NotNil(t, l)
assert.GreaterOrEqual(t, l.Burst(), 1, "burst must never round down to 0")
}

View File

@@ -0,0 +1,62 @@
package s3_lifecycle
import (
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
const (
jobType = "s3_lifecycle"
// In-process fan-out across the 16 shards.
shardPipelineGoroutines = 1
)
type Config struct {
Workers int
MetaLogRetention time.Duration
// WalkerInterval is the minimum time between steady-state walker
// fires per shard. 0 means "fire on every run", preserving prior
// behavior; positive values gate the walker via Cursor.LastWalkedNs
// inside dailyrun.runShard.
WalkerInterval time.Duration
}
func ParseConfig(adminValues map[string]*plugin_pb.ConfigValue, workerValues map[string]*plugin_pb.ConfigValue) Config {
cfg := Config{
Workers: shardPipelineGoroutines,
}
_ = workerValues // worker-side config form is currently empty; reserved for future per-worker tuning.
// Operator-declared meta-log retention. Negative or zero values stay
// zero so runShard falls back to maxTTL (PromotedHash dormant).
// Convert days->hours in int64 space before lifting to time.Duration
// so the unit is unambiguous.
if days := readInt64(adminValues, MetaLogRetentionDaysAdminKey, 0); days > 0 {
cfg.MetaLogRetention = time.Duration(days*24) * time.Hour
}
// Walker throttle. Negative / zero stay zero so dailyrun.runShard
// keeps the prior "fire every pass" semantics — important for in-
// repo integration tests and s3tests's sub-minute driver. Positive
// values throttle the steady-state walker per shard.
if mins := readInt64(adminValues, WalkerIntervalMinutesAdminKey, 0); mins > 0 {
cfg.WalkerInterval = time.Duration(mins) * time.Minute
}
return cfg
}
func readInt64(values map[string]*plugin_pb.ConfigValue, field string, fallback int64) int64 {
v, ok := values[field]
if !ok || v == nil {
return fallback
}
switch k := v.Kind.(type) {
case *plugin_pb.ConfigValue_Int64Value:
return k.Int64Value
case *plugin_pb.ConfigValue_DoubleValue:
return int64(k.DoubleValue)
case *plugin_pb.ConfigValue_StringValue:
return fallback
}
return fallback
}

View File

@@ -0,0 +1,95 @@
package s3_lifecycle
import (
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
func TestParseConfigDefaults(t *testing.T) {
cfg := ParseConfig(nil, nil)
if cfg.Workers != shardPipelineGoroutines {
t.Errorf("Workers default=%d, want %d", cfg.Workers, shardPipelineGoroutines)
}
}
func TestParseConfigIgnoresWorkerValues(t *testing.T) {
// Worker-side config form is currently empty. The old
// max_runtime_minutes knob duplicated the admin scheduler's
// Execution Timeout and was removed; anything in workerValues is
// now silently ignored. The single source of truth for the
// per-Execute wall-clock cap is AdminRuntimeDefaults.ExecutionTimeoutSeconds.
worker := map[string]*plugin_pb.ConfigValue{
"max_runtime_minutes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 120}},
}
cfg := ParseConfig(nil, worker)
if cfg.Workers != shardPipelineGoroutines {
t.Errorf("Workers default=%d, want %d", cfg.Workers, shardPipelineGoroutines)
}
}
func TestParseConfigMetaLogRetentionDefaultsToZero(t *testing.T) {
// Unset key keeps MetaLogRetention at 0, which runShard treats as
// "no retention info supplied" and falls back to maxTTL.
cfg := ParseConfig(nil, nil)
if cfg.MetaLogRetention != 0 {
t.Errorf("MetaLogRetention default=%v, want 0", cfg.MetaLogRetention)
}
}
func TestParseConfigMetaLogRetentionDaysConvertsToDuration(t *testing.T) {
admin := map[string]*plugin_pb.ConfigValue{
MetaLogRetentionDaysAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7}},
}
cfg := ParseConfig(admin, nil)
if want := 7 * 24 * time.Hour; cfg.MetaLogRetention != want {
t.Errorf("MetaLogRetention=%v, want %v (7 days)", cfg.MetaLogRetention, want)
}
}
func TestParseConfigMetaLogRetentionNegativeStaysZero(t *testing.T) {
// A negative declaration is nonsense; stay at 0 so runShard's
// fallback applies rather than producing a negative window.
admin := map[string]*plugin_pb.ConfigValue{
MetaLogRetentionDaysAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: -3}},
}
cfg := ParseConfig(admin, nil)
if cfg.MetaLogRetention != 0 {
t.Errorf("negative MetaLogRetention should stay 0, got %v", cfg.MetaLogRetention)
}
}
func TestParseConfigWalkerIntervalDefaultsToZero(t *testing.T) {
// Unset key keeps WalkerInterval at 0 so dailyrun.runShard fires the
// walker every pass (the pre-throttle behavior the s3tests fast
// driver and the in-repo integration tests rely on).
cfg := ParseConfig(nil, nil)
if cfg.WalkerInterval != 0 {
t.Errorf("WalkerInterval default=%v, want 0", cfg.WalkerInterval)
}
}
func TestParseConfigWalkerIntervalMinutesConvertsToDuration(t *testing.T) {
admin := map[string]*plugin_pb.ConfigValue{
WalkerIntervalMinutesAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 90}},
}
cfg := ParseConfig(admin, nil)
if want := 90 * time.Minute; cfg.WalkerInterval != want {
t.Errorf("WalkerInterval=%v, want %v", cfg.WalkerInterval, want)
}
}
func TestParseConfigWalkerIntervalNegativeStaysZero(t *testing.T) {
// Negative declarations stay at 0 so the worker keeps "fire every
// pass" rather than treating the negative as past-due (which would
// fire every pass anyway — but via a less obvious code path that
// future readers would have to trace).
admin := map[string]*plugin_pb.ConfigValue{
WalkerIntervalMinutesAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: -10}},
}
cfg := ParseConfig(admin, nil)
if cfg.WalkerInterval != 0 {
t.Errorf("negative WalkerInterval should stay 0, got %v", cfg.WalkerInterval)
}
}

View File

@@ -0,0 +1,453 @@
package s3_lifecycle
import (
"context"
"fmt"
"math"
"strconv"
"time"
"github.com/seaweedfs/seaweedfs/weed/credential"
"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"
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dailyrun"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dispatcher"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/scheduler"
"golang.org/x/time/rate"
"google.golang.org/grpc"
)
func init() {
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
JobType: jobType,
Category: pluginworker.CategoryDefault,
Aliases: []string{"s3-lifecycle", "s3.lifecycle", "lifecycle"},
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
return NewHandler(opts.GrpcDialOption), nil
},
})
}
// Handler is the worker-side runner for S3 object lifecycle expiration.
// One Execute call drives one bounded dailyrun.Run pass against the S3
// endpoints discovered from the master; admin caps concurrency at one
// job per worker so a fresh proposal only spawns a new run after the
// prior one exits.
type Handler struct {
grpcDialOption grpc.DialOption
}
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: 1,
DisplayName: "S3 Lifecycle",
Description: "Daily batch: scan the filer meta-log and delete objects whose lifecycle rule has fired.",
Weight: 20,
}
}
func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
return &plugin_pb.JobTypeDescriptor{
JobType: jobType,
DisplayName: "S3 Lifecycle",
Description: "Daily S3 object expiration scan with per-shard cursors and bounded retry.",
Icon: "fas fa-recycle",
DescriptorVersion: 1,
AdminConfigForm: &plugin_pb.ConfigForm{
FormId: "s3-lifecycle-admin",
Title: "S3 Lifecycle",
Description: "Cluster-wide delete-throughput cap.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "scope",
Title: "Scope",
Description: "Cluster-wide delete-throughput cap.",
Fields: []*plugin_pb.ConfigField{
{
Name: ClusterDeletesPerSecondAdminKey,
Label: "Cluster Delete Rate (per second)",
Description: "Cluster-wide ceiling on lifecycle delete RPCs per second, divided evenly across active s3_lifecycle workers at job-dispatch time. 0 = unlimited.",
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}},
},
{
Name: ClusterDeletesBurstAdminKey,
Label: "Cluster Delete Burst",
Description: "Token-bucket burst capacity across the cluster (max simultaneous deletes). 0 = 2 × rate. Same allocation rule as the rate.",
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}},
},
{
Name: MetaLogRetentionDaysAdminKey,
Label: "Meta-Log Retention (days)",
Description: "How far back the filer's meta-log subscription can reach. Rules whose TTL exceeds this run via the walker; shrinking this value will trigger a one-time recovery walk on the next run for any rule that's now too old to replay. 0 = unbounded (no partition; every rule serviced by replay).",
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}},
},
{
Name: WalkerIntervalMinutesAdminKey,
Label: "Walker Interval (minutes)",
Description: "Minimum time between steady-state walker fires per shard. Cold-start and rule-change recovery walks ignore this — they run unconditionally. 0 = fire on every run (use when the worker is scheduled at the desired walk cadence, e.g. hourly). Set to a positive value when the worker runs at a tighter cadence than the desired walk frequency, to avoid hammering filer with a full subtree scan per run.",
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{
ClusterDeletesPerSecondAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
ClusterDeletesBurstAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
MetaLogRetentionDaysAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
WalkerIntervalMinutesAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
},
},
// WorkerConfigForm intentionally absent — the prior
// "Per-Run Time Limit (minutes)" knob duplicated the admin
// scheduler's Execution Timeout (both wall-clock caps on the
// same Execute call), and operators had to keep the two values
// in agreement. Removed in favor of a single source of truth:
// AdminRuntimeDefaults.ExecutionTimeoutSeconds below.
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
// On by default: S3 lifecycle is a standard bucket feature
// (PutBucketLifecycleConfiguration is part of the S3 API),
// and a bucket with rules set but no worker running silently
// retains data past its declared expiration. Operators who
// want the worker off can still disable it in the admin UI;
// the default error is "data lingers" not "worker burns CPU
// on empty rule sets" (the worker fast-exits with no
// configured rules).
Enabled: true,
DetectionIntervalMinutes: 24 * 60, // daily
DetectionTimeoutSeconds: 60,
MaxJobsPerDetection: 1,
// Effectively no per-pass wall-clock cap. Lifecycle is a
// scheduled batch — its natural duration is "as long as it
// takes to process today's events." The scheduler's global
// 90s default would kill every real run, and a numeric
// cap operators have to estimate (1h? 8h?) is a recurring
// footgun: too low truncates a legitimate large-bucket
// pass; too high makes the value meaningless.
//
// Use math.MaxInt32 seconds (~68 years) for both knobs to
// say "no timeout in practice" in code-review-readable form.
// Operators who genuinely want a cap can set one in the
// admin UI; the underlying context.WithTimeout machinery
// is unchanged.
ExecutionTimeoutSeconds: math.MaxInt32,
JobTypeMaxRuntimeSeconds: math.MaxInt32,
},
}
}
func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionRequest, sender pluginworker.DetectionSender) error {
if request == nil || sender == nil {
return fmt.Errorf("detect: nil request or sender")
}
if request.JobType != "" && request.JobType != jobType {
return fmt.Errorf("job type %q is not handled by s3 lifecycle handler", request.JobType)
}
s3Endpoints := clusterS3Endpoints(request.ClusterContext)
if len(s3Endpoints) == 0 {
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("skipped", "no s3 servers registered with master", nil))
return sender.SendComplete(&plugin_pb.DetectionComplete{JobType: jobType, Success: true})
}
filerAddresses := []string{}
if request.ClusterContext != nil {
filerAddresses = append(filerAddresses, request.ClusterContext.FilerAddresses...)
}
if len(filerAddresses) == 0 {
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("skipped", "no filer addresses in cluster context", nil))
return sender.SendComplete(&plugin_pb.DetectionComplete{JobType: jobType, Success: true})
}
// FilerAddresses are pb.ServerAddress strings (host:httpPort.grpcPort);
// Execute dials filer_grpc_address verbatim, so resolve it to a gRPC address.
filerGrpcAddress := pb.ServerAddress(filerAddresses[0]).ToGrpcAddress()
proposal := &plugin_pb.JobProposal{
JobType: jobType,
ProposalId: fmt.Sprintf("s3-lifecycle-%d", time.Now().UnixNano()),
Priority: plugin_pb.JobPriority_JOB_PRIORITY_NORMAL,
Parameters: map[string]*plugin_pb.ConfigValue{
"filer_grpc_address": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: filerGrpcAddress}},
},
}
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
JobType: jobType,
Proposals: []*plugin_pb.JobProposal{proposal},
}); err != nil {
return err
}
return sender.SendComplete(&plugin_pb.DetectionComplete{
JobType: jobType,
Success: true,
TotalProposals: 1,
})
}
func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender) error {
if request == nil || request.Job == nil || sender == nil {
return fmt.Errorf("execute: nil request/job/sender")
}
if request.Job.JobType != "" && request.Job.JobType != jobType {
return fmt.Errorf("job type %q is not handled by s3 lifecycle handler", request.Job.JobType)
}
cfg := ParseConfig(request.GetAdminConfigValues(), request.GetWorkerConfigValues())
s3Endpoints := clusterS3Endpoints(request.ClusterContext)
if len(s3Endpoints) == 0 {
return fmt.Errorf("execute: no s3 servers registered with master")
}
filerAddress := readString(request.Job.Parameters, "filer_grpc_address", "")
if filerAddress == "" {
return fmt.Errorf("execute: missing filer_grpc_address in job parameters")
}
// Run lifetime is bounded by the scheduler's Execution Timeout
// (admin UI). The handler used to wrap ctx in another
// context.WithTimeout(cfg.MaxRuntime), but that doubled the
// concept — both knobs were wall-clock caps on the same Execute
// call, and the smaller one always won (typically the 90s
// scheduler default would clobber a 60-min worker setting).
// Single source of truth is now AdminRuntimeDefaults.ExecutionTimeoutSeconds.
runCtx := ctx
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId, JobType: jobType,
State: plugin_pb.JobState_JOB_STATE_RUNNING, Stage: "starting",
Message: fmt.Sprintf("scheduler workers=%d s3=%v", cfg.Workers, s3Endpoints),
})
dialCtx, dialCancel := context.WithTimeout(runCtx, 30*time.Second)
filerConn, err := pb.GrpcDial(dialCtx, filerAddress, false, h.grpcDialOption)
dialCancel()
if err != nil {
return fmt.Errorf("dial filer %s: %w", filerAddress, err)
}
defer filerConn.Close()
filerClient := filer_pb.NewSeaweedFilerClient(filerConn)
bucketsPath, err := lookupBucketsPath(runCtx, filerClient)
if err != nil {
return fmt.Errorf("buckets path: %w", err)
}
dialCtx, dialCancel = context.WithTimeout(runCtx, 30*time.Second)
s3Conn, err := pb.GrpcDial(dialCtx, s3Endpoints[0], false, h.grpcDialOption)
dialCancel()
if err != nil {
return fmt.Errorf("dial s3 %s: %w", s3Endpoints[0], err)
}
defer s3Conn.Close()
rpc := s3_lifecycle_pb.NewSeaweedS3LifecycleInternalClient(s3Conn)
if err := h.executeDailyReplay(runCtx, request, bucketsPath, filerClient, rpc, cfg, sender); err != nil {
return err
}
return sendSuccessCompletion(request, sender)
}
const dailyReplaySuccessSummary = "s3 lifecycle daily replay completed"
// JobCompleted must set JobType to the handler's jobType constant;
// routed requests may leave request.Job.JobType empty, and the admin
// ignores completions with empty JobType.
func sendSuccessCompletion(request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender) error {
return sender.SendCompleted(&plugin_pb.JobCompleted{
JobId: request.Job.JobId,
JobType: jobType,
Success: true,
Result: &plugin_pb.JobResult{
Summary: dailyReplaySuccessSummary,
},
})
}
// executeDailyReplay runs one bounded daily-replay pass via
// dailyrun.Run. The walker fires inside runShard on rule-content edits
// and against the steady-state walk view; all rule kinds are serviced.
func (h *Handler) executeDailyReplay(ctx context.Context, request *plugin_pb.ExecuteJobRequest, bucketsPath string, filerClient filer_pb.SeaweedFilerClient, rpc s3_lifecycle_pb.SeaweedS3LifecycleInternalClient, cfg Config, sender pluginworker.ExecutionSender) error {
eng := engine.New()
inputs, parseErrors, err := scheduler.LoadCompileInputs(ctx, filerClient, bucketsPath)
if err != nil {
return fmt.Errorf("daily_replay: load lifecycle inputs: %w", err)
}
for _, pe := range parseErrors {
glog.V(1).Infof("daily_replay: %s: %v", pe.Bucket, pe.Err)
}
eng.Compile(inputs, engine.CompileOptions{PriorStates: scheduler.AllActivePriorStates(inputs)})
shards := make([]int, 0, s3lifecycle.ShardCount)
for i := 0; i < s3lifecycle.ShardCount; i++ {
shards = append(shards, i)
}
limiter, limiterDesc := buildLimiterFromClusterContext(request.GetClusterContext())
// Reuse one LifecycleClient across the replay drain and the
// walker's per-entry dispatch.
client := lifecycleRPCAdapter{c: rpc}
// Bucket list for the walker — derived from inputs so it matches
// the snapshot the engine compiled.
buckets := make([]string, 0, len(inputs))
for _, in := range inputs {
if in.Bucket != "" {
buckets = append(buckets, in.Bucket)
}
}
walkerListFn := dailyrun.FilerListFunc(filerClient, bucketsPath)
// Share the limiter with processMatches so walker + replay can't
// combine to burst past the cluster cap.
walkerDispatch := &dailyrun.WalkerDispatcher{Client: client, Limiter: limiter}
walker := dailyrun.WalkerFunc(func(walkCtx context.Context, view *engine.Snapshot, shardID int) error {
return dailyrun.WalkBuckets(walkCtx, view, shardID, buckets, walkerListFn, walkerDispatch)
})
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId, JobType: jobType,
State: plugin_pb.JobState_JOB_STATE_RUNNING, Stage: "starting",
Message: fmt.Sprintf("daily_replay shards=%d workers=%d rate=%s buckets=%d walker=on",
len(shards), cfg.Workers, limiterDesc, len(buckets)),
})
runErr := dailyrun.Run(ctx, dailyrun.Config{
Shards: shards,
BucketsPath: bucketsPath,
Engine: eng,
FilerClient: filerClient,
Client: client,
Persister: &dailyrun.FilerCursorPersister{Store: dispatcher.NewFilerStoreClient(filerClient)},
Lister: dispatcher.NewFilerSiblingLister(filerClient, bucketsPath),
Workers: cfg.Workers,
Limiter: limiter,
RetentionWindow: cfg.MetaLogRetention,
Walker: walker,
WalkerInterval: cfg.WalkerInterval,
ClientName: "worker-s3-lifecycle-daily",
})
if runErr != nil {
glog.Warningf("daily_replay: %v", runErr)
return runErr
}
return nil
}
// buildLimiterFromClusterContext parses the per-worker share the admin
// wrote into ClusterContext.Metadata (see weed/admin/plugin/plugin.go's
// s3_lifecycle injection) and returns a rate.Limiter, or nil when no
// rate cap applies. The description string is for the JobProgressUpdate
// so operators can see "rate=unlimited" / "rate=12.5/s burst=25" in
// the activity log.
//
// Tolerant of missing keys, empty strings, malformed numbers, and
// non-positive values — all treated as "no limit" rather than failing
// the run. The admin allocator is the single point that decides whether
// to populate these keys; the worker doesn't second-guess.
func buildLimiterFromClusterContext(cc *plugin_pb.ClusterContext) (*rate.Limiter, string) {
if cc == nil || cc.Metadata == nil {
return nil, "unlimited"
}
rps, ok := parsePositiveFloat(cc.Metadata[MetadataKeyDeletesPerSecond])
if !ok {
return nil, "unlimited"
}
burst, _ := parsePositiveInt(cc.Metadata[MetadataKeyDeletesBurst])
if burst <= 0 {
// Sensible default: enough headroom for one tick's worth of
// throughput. Caller may also supply 0 to opt into this default.
burst = int(rps * 2)
if burst < 1 {
burst = 1
}
}
return rate.NewLimiter(rate.Limit(rps), burst), fmt.Sprintf("%.3g/s burst=%d", rps, burst)
}
func parsePositiveFloat(s string) (float64, bool) {
if s == "" {
return 0, false
}
v, err := strconv.ParseFloat(s, 64)
if err != nil || v <= 0 {
return 0, false
}
return v, true
}
func parsePositiveInt(s string) (int, bool) {
if s == "" {
return 0, false
}
v, err := strconv.Atoi(s)
if err != nil || v <= 0 {
return 0, false
}
return v, true
}
// clusterS3Endpoints returns the master-discovered S3 gRPC addresses for the
// cluster. The handler dials the first reachable one; the master refreshes
// the list on KeepConnected so a stale entry self-heals on the next run.
func clusterS3Endpoints(cc *plugin_pb.ClusterContext) []string {
if cc == nil {
return nil
}
out := make([]string, 0, len(cc.S3GrpcAddresses))
for _, addr := range cc.S3GrpcAddresses {
if addr != "" {
out = append(out, addr)
}
}
return out
}
type lifecycleRPCAdapter struct {
c s3_lifecycle_pb.SeaweedS3LifecycleInternalClient
}
func (a lifecycleRPCAdapter) LifecycleDelete(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) {
ctx, _ = credential.WithS3InternalAdminAuth(ctx)
return a.c.LifecycleDelete(ctx, req)
}
func lookupBucketsPath(ctx context.Context, client filer_pb.SeaweedFilerClient) (string, error) {
resp, err := client.GetFilerConfiguration(ctx, &filer_pb.GetFilerConfigurationRequest{})
if err != nil {
return "", err
}
if path := resp.GetDirBuckets(); path != "" {
return path, nil
}
return "/buckets", nil
}
func readString(values map[string]*plugin_pb.ConfigValue, field, fallback string) string {
v, ok := values[field]
if !ok || v == nil {
return fallback
}
if k, ok := v.Kind.(*plugin_pb.ConfigValue_StringValue); ok {
return k.StringValue
}
return fallback
}
var _ pluginworker.JobHandler = (*Handler)(nil)

View File

@@ -0,0 +1,550 @@
package s3_lifecycle
import (
"context"
"errors"
"math"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
// Tests cover the worker-handler surface that runs without a live filer
// or S3 server: pure helpers (clusterS3Endpoints, readString),
// Capability/Descriptor sanity, and the Detect input-validation /
// skip-activity / proposal paths driven by a recorder sender.
// recordingSender captures everything the handler sends so each Detect
// case can assert on activities, proposals, and completion.
type recordingSender struct {
proposals []*plugin_pb.DetectionProposals
completes []*plugin_pb.DetectionComplete
activities []*plugin_pb.ActivityEvent
// errOn forces the named send to fail; lets tests cover the
// SendComplete error-propagation path without a full transport stub.
errOn map[string]error
}
func (r *recordingSender) SendProposals(p *plugin_pb.DetectionProposals) error {
if err := r.errOn["proposals"]; err != nil {
return err
}
r.proposals = append(r.proposals, p)
return nil
}
func (r *recordingSender) SendComplete(c *plugin_pb.DetectionComplete) error {
if err := r.errOn["complete"]; err != nil {
return err
}
r.completes = append(r.completes, c)
return nil
}
func (r *recordingSender) SendActivity(a *plugin_pb.ActivityEvent) error {
if err := r.errOn["activity"]; err != nil {
return err
}
r.activities = append(r.activities, a)
return nil
}
// ---------- clusterS3Endpoints ----------
func TestClusterS3Endpoints_NilContext(t *testing.T) {
// A nil ClusterContext occurs in early-bootstrap detect calls; the
// handler must return an empty slice rather than panic.
assert.Nil(t, clusterS3Endpoints(nil))
}
func TestClusterS3Endpoints_EmptyList(t *testing.T) {
assert.Empty(t, clusterS3Endpoints(&plugin_pb.ClusterContext{}))
}
func TestClusterS3Endpoints_FiltersEmptyEntries(t *testing.T) {
// An empty-string address represents a stale registry entry the
// master is about to evict; never dial that. Order of the surviving
// entries must be preserved so the handler dials a deterministic
// host across detect runs.
cc := &plugin_pb.ClusterContext{S3GrpcAddresses: []string{"s3a:8333", "", "s3b:8333", ""}}
assert.Equal(t, []string{"s3a:8333", "s3b:8333"}, clusterS3Endpoints(cc))
}
func TestClusterS3Endpoints_AllValid(t *testing.T) {
cc := &plugin_pb.ClusterContext{S3GrpcAddresses: []string{"s3a:8333", "s3b:8333"}}
assert.Equal(t, []string{"s3a:8333", "s3b:8333"}, clusterS3Endpoints(cc))
}
// ---------- readString ----------
func TestReadString_MissingKeyReturnsFallback(t *testing.T) {
got := readString(map[string]*plugin_pb.ConfigValue{}, "missing", "fallback")
assert.Equal(t, "fallback", got)
}
func TestReadString_NilValueReturnsFallback(t *testing.T) {
got := readString(map[string]*plugin_pb.ConfigValue{"k": nil}, "k", "fallback")
assert.Equal(t, "fallback", got)
}
func TestReadString_WrongKindReturnsFallback(t *testing.T) {
// Configs are typed; an Int64 in a string slot is a writer-side bug
// the handler must tolerate rather than panic on a type assertion.
values := map[string]*plugin_pb.ConfigValue{
"k": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 42}},
}
assert.Equal(t, "fallback", readString(values, "k", "fallback"))
}
func TestReadString_StringValueReturned(t *testing.T) {
values := map[string]*plugin_pb.ConfigValue{
"k": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "expected"}},
}
assert.Equal(t, "expected", readString(values, "k", "fallback"))
}
// ---------- Capability ----------
func TestCapability_AdvertisesJobType(t *testing.T) {
h := NewHandler(nil)
cap := h.Capability()
require.NotNil(t, cap)
assert.Equal(t, jobType, cap.JobType)
assert.True(t, cap.CanDetect)
assert.True(t, cap.CanExecute)
// Admin caps concurrency at one job per worker; a rebuild of the
// scheduler must wait for the prior one to exit.
assert.Equal(t, int32(1), cap.MaxDetectionConcurrency)
assert.Equal(t, int32(1), cap.MaxExecutionConcurrency)
}
// ---------- Detect ----------
func TestDetect_NilRequestErrors(t *testing.T) {
h := NewHandler(nil)
r := &recordingSender{}
err := h.Detect(context.Background(), nil, r)
require.Error(t, err)
assert.Empty(t, r.proposals)
assert.Empty(t, r.completes)
}
func TestDetect_NilSenderErrors(t *testing.T) {
h := NewHandler(nil)
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{}, nil)
require.Error(t, err)
}
func TestDetect_WrongJobTypeErrors(t *testing.T) {
// A request routed to this handler with a foreign JobType is the
// admin's bug; surface as an error so it's visible rather than
// silently emitting a bogus proposal.
h := NewHandler(nil)
r := &recordingSender{}
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{JobType: "different_job"}, r)
require.Error(t, err)
assert.Contains(t, err.Error(), "different_job")
}
func TestDetect_NoS3EndpointsCompletesWithSkipActivity(t *testing.T) {
// A cluster with no S3 servers registered yet must not spawn the
// scheduler; emit a "skipped" activity for operator visibility and
// complete with success so the admin doesn't classify as a failure.
h := NewHandler(nil)
r := &recordingSender{}
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: jobType,
ClusterContext: &plugin_pb.ClusterContext{},
}, r)
require.NoError(t, err)
assert.Empty(t, r.proposals, "no S3 endpoints must not yield a proposal")
require.Len(t, r.activities, 1)
assert.Equal(t, "skipped", r.activities[0].Stage)
assert.Contains(t, r.activities[0].Message, "no s3 servers")
require.Len(t, r.completes, 1)
assert.True(t, r.completes[0].Success, "skip is success, not failure")
assert.Equal(t, jobType, r.completes[0].JobType)
}
func TestDetect_NoFilerAddressesCompletesWithSkipActivity(t *testing.T) {
h := NewHandler(nil)
r := &recordingSender{}
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: jobType,
ClusterContext: &plugin_pb.ClusterContext{
S3GrpcAddresses: []string{"s3a:8333"},
// no FilerAddresses
},
}, r)
require.NoError(t, err)
assert.Empty(t, r.proposals)
require.Len(t, r.activities, 1)
assert.Equal(t, "skipped", r.activities[0].Stage)
assert.Contains(t, r.activities[0].Message, "no filer addresses")
require.Len(t, r.completes, 1)
assert.True(t, r.completes[0].Success)
}
func TestDetect_HappyPathProposesOneJobWithFirstFilerAddress(t *testing.T) {
// Detect must propose exactly one job that targets the first filer
// address in the cluster context; the master refreshes the list so
// a stale entry self-heals on the next run.
h := NewHandler(nil)
r := &recordingSender{}
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: jobType,
ClusterContext: &plugin_pb.ClusterContext{
S3GrpcAddresses: []string{"s3a:8333"},
// pb.ServerAddress dual-port form; the gRPC port is pinned off the
// +10000 convention so a raw-forwarding regression resurfaces here.
FilerAddresses: []string{"filer-a:8888.18890", "filer-b:8888.18890"},
},
}, r)
require.NoError(t, err)
assert.Empty(t, r.activities, "happy path emits no skip activity")
require.Len(t, r.proposals, 1)
require.Len(t, r.proposals[0].Proposals, 1)
prop := r.proposals[0].Proposals[0]
assert.Equal(t, jobType, prop.JobType)
assert.NotEmpty(t, prop.ProposalId, "proposal id must be unique-per-run")
require.Contains(t, prop.Parameters, "filer_grpc_address")
val := prop.Parameters["filer_grpc_address"].GetStringValue()
assert.Equal(t, "filer-a:18890", val, "first filer resolved to its gRPC port, not host:28890")
require.Len(t, r.completes, 1)
assert.True(t, r.completes[0].Success)
assert.Equal(t, int32(1), r.completes[0].TotalProposals)
}
func TestDetect_EmptyJobTypeAccepted(t *testing.T) {
// Detect is sometimes invoked with an unset JobType (broadcast
// detect); the handler must accept and behave as if it matched.
h := NewHandler(nil)
r := &recordingSender{}
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
ClusterContext: &plugin_pb.ClusterContext{
S3GrpcAddresses: []string{"s3a:8333"},
FilerAddresses: []string{"f:8888.18890"},
},
}, r)
require.NoError(t, err)
require.Len(t, r.proposals, 1)
}
func TestDetect_PropagatesProposalsSendError(t *testing.T) {
// SendProposals failing must propagate; otherwise the worker would
// silently report success despite never delivering the proposal.
h := NewHandler(nil)
want := errors.New("transport down")
r := &recordingSender{errOn: map[string]error{"proposals": want}}
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: jobType,
ClusterContext: &plugin_pb.ClusterContext{
S3GrpcAddresses: []string{"s3a:8333"},
FilerAddresses: []string{"f:8888.18890"},
},
}, r)
assert.ErrorIs(t, err, want)
assert.Empty(t, r.proposals)
assert.Empty(t, r.completes, "complete must not fire when proposals fail")
}
func TestDetect_PropagatesCompleteSendError(t *testing.T) {
// SendComplete failing must propagate; otherwise the worker would
// report success to the admin despite the completion signal never
// landing. Proposals went out before the failure, so they remain in
// the recorder.
h := NewHandler(nil)
want := errors.New("transport down")
r := &recordingSender{errOn: map[string]error{"complete": want}}
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: jobType,
ClusterContext: &plugin_pb.ClusterContext{
S3GrpcAddresses: []string{"s3a:8333"},
FilerAddresses: []string{"f:8888.18890"},
},
}, r)
assert.ErrorIs(t, err, want)
assert.Len(t, r.proposals, 1, "proposals send before complete and remain recorded")
assert.Empty(t, r.completes)
}
// ---------- Descriptor ----------
func TestDescriptor_BasicShape(t *testing.T) {
// Sanity-check the Descriptor's public-facing identifiers so a
// rename in handler.go doesn't silently break the admin UI without
// an admin-side change too.
h := NewHandler(nil)
d := h.Descriptor()
require.NotNil(t, d)
assert.Equal(t, jobType, d.JobType)
assert.NotEmpty(t, d.DisplayName)
assert.NotEmpty(t, d.Description)
assert.Greater(t, d.DescriptorVersion, uint32(0), "descriptor version must be positive (admins use it for compat)")
}
func TestDescriptor_AdminConfigFormHasNoWorkersField(t *testing.T) {
// "workers" used to be an admin form field controlling in-process
// pipeline goroutines. It's per-worker tuning, not a cluster-wide
// scope concern, so it was removed from the form. ParseConfig hard-
// codes cfg.Workers from shardPipelineGoroutines instead.
h := NewHandler(nil)
d := h.Descriptor()
require.NotNil(t, d.AdminConfigForm)
for _, sec := range d.AdminConfigForm.Sections {
for _, f := range sec.Fields {
assert.NotEqual(t, "workers", f.Name, "admin form must NOT expose the in-memory pipeline-goroutine knob")
}
}
_, hasDefault := d.AdminConfigForm.DefaultValues["workers"]
assert.False(t, hasDefault, "DefaultValues must NOT include 'workers' (form field removed)")
}
func TestDescriptor_WorkerConfigFormIsAbsent(t *testing.T) {
// "Per-Run Time Limit (minutes)" was the only worker-side knob and
// duplicated the admin scheduler's Execution Timeout (both are
// wall-clock caps on the same Execute call). Removed in favor of
// AdminRuntimeDefaults.ExecutionTimeoutSeconds — single source of
// truth. A WorkerConfigForm with no fields would render as an
// empty section in the admin UI; drop the form entirely.
h := NewHandler(nil)
d := h.Descriptor()
assert.Nil(t, d.WorkerConfigForm,
"WorkerConfigForm should be nil now that max_runtime_minutes is gone; if you re-add a worker-side knob, restore the form and pin it here")
}
func TestDescriptor_AdminRuntimeDefaultsHaveNoTimeoutInPractice(t *testing.T) {
// Lifecycle is a scheduled batch whose natural duration is "as
// long as today's events take." The scheduler's global 90s default
// would kill every real run, and a numeric cap operators have to
// estimate is a footgun (too low truncates a legitimate
// large-bucket pass; too high makes the value meaningless). Declare
// math.MaxInt32 seconds for both knobs to say "no timeout in
// practice" in a code-review-readable way. A future change that
// tightens the cap should fail this test so the choice is
// re-examined consciously.
h := NewHandler(nil)
d := h.Descriptor()
require.NotNil(t, d.AdminRuntimeDefaults)
assert.Equal(t, int32(math.MaxInt32), d.AdminRuntimeDefaults.ExecutionTimeoutSeconds,
"ExecutionTimeoutSeconds should be effectively unlimited; the scheduler's 90s default would otherwise clobber the worker mid-run")
assert.Equal(t, int32(math.MaxInt32), d.AdminRuntimeDefaults.JobTypeMaxRuntimeSeconds,
"JobTypeMaxRuntimeSeconds is the per-pass budget — keep aligned with ExecutionTimeoutSeconds")
}
func TestDescriptor_AdminRuntimeDefaultsDailyCadence(t *testing.T) {
// Lifecycle is a daily batch; the admin must default to a 24-hour
// detection interval so cron pressure doesn't escalate. Bound the
// detection timeout so a stuck detect can't pin a worker slot
// indefinitely. Max 1 job per detection = scheduler runs alone.
h := NewHandler(nil)
d := h.Descriptor()
require.NotNil(t, d.AdminRuntimeDefaults)
assert.Equal(t, int32(24*60), d.AdminRuntimeDefaults.DetectionIntervalMinutes)
assert.Equal(t, int32(60), d.AdminRuntimeDefaults.DetectionTimeoutSeconds, "60s timeout caps a stuck detect at one minute")
assert.Equal(t, int32(1), d.AdminRuntimeDefaults.MaxJobsPerDetection)
}
func TestDescriptor_AdminRuntimeDefaultsEnabledByDefault(t *testing.T) {
// S3 lifecycle is a standard bucket feature — operators set
// PutBucketLifecycleConfiguration expecting it to actually fire.
// Default-off would silently retain data past declared expiration
// until an operator notices and flips it on. Document the choice
// here so a future change to disabled-by-default fails the test
// and surfaces a conscious revisit.
h := NewHandler(nil)
d := h.Descriptor()
require.NotNil(t, d.AdminRuntimeDefaults)
assert.True(t, d.AdminRuntimeDefaults.Enabled,
"s3_lifecycle defaults must enable the scheduler so configured rules fire without operator opt-in")
}
// ---------- Execute ----------
// recordingExecSender captures Execute-side messages. The Execute path
// dials gRPC after passing validation, so these tests only exercise
// the validation surface that errors out before any dial — proving
// the handler refuses malformed jobs early instead of waiting on a
// 30s dial timeout.
type recordingExecSender struct {
progress []*plugin_pb.JobProgressUpdate
completed []*plugin_pb.JobCompleted
completedErr error
}
func (r *recordingExecSender) SendProgress(p *plugin_pb.JobProgressUpdate) error {
r.progress = append(r.progress, p)
return nil
}
func (r *recordingExecSender) SendCompleted(c *plugin_pb.JobCompleted) error {
if r.completedErr != nil {
return r.completedErr
}
r.completed = append(r.completed, c)
return nil
}
func TestExecute_NilRequestErrors(t *testing.T) {
h := NewHandler(nil)
err := h.Execute(context.Background(), nil, &recordingExecSender{})
require.Error(t, err)
assert.Contains(t, err.Error(), "nil")
}
func TestExecute_NilJobErrors(t *testing.T) {
// A non-nil request with nil Job is a writer-side bug; refuse it
// rather than panic dereferencing request.Job.JobType.
h := NewHandler(nil)
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{}, &recordingExecSender{})
require.Error(t, err)
assert.Contains(t, err.Error(), "nil")
}
func TestExecute_NilSenderErrors(t *testing.T) {
h := NewHandler(nil)
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
Job: &plugin_pb.JobSpec{JobType: jobType},
}, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "nil")
}
func TestExecute_WrongJobTypeErrors(t *testing.T) {
// A foreign job type routed to this handler is the admin's bug;
// surface as an error rather than running a bogus scheduler.
h := NewHandler(nil)
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
Job: &plugin_pb.JobSpec{JobType: "different_job"},
}, &recordingExecSender{})
require.Error(t, err)
assert.Contains(t, err.Error(), "different_job")
}
func TestExecute_NoS3EndpointsErrors(t *testing.T) {
// Detect emits a "skipped" activity for this case; Execute is
// stricter — the admin shouldn't have routed an Execute request
// without S3 endpoints, so error out instead of silently no-oping.
h := NewHandler(nil)
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
Job: &plugin_pb.JobSpec{JobType: jobType},
ClusterContext: &plugin_pb.ClusterContext{}, // no S3GrpcAddresses
}, &recordingExecSender{})
require.Error(t, err)
assert.Contains(t, err.Error(), "no s3 servers")
}
func TestExecute_MissingFilerAddressErrors(t *testing.T) {
// filer_grpc_address is set by Detect when it builds the proposal;
// missing it means the proposal was tampered with or the admin
// dropped the parameter. Refuse rather than dial nothing.
h := NewHandler(nil)
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
Job: &plugin_pb.JobSpec{
JobType: jobType,
Parameters: map[string]*plugin_pb.ConfigValue{}, // no filer_grpc_address
},
ClusterContext: &plugin_pb.ClusterContext{
S3GrpcAddresses: []string{"s3a:8333"},
},
}, &recordingExecSender{})
require.Error(t, err)
assert.Contains(t, err.Error(), "filer_grpc_address")
}
func TestExecute_EmptyJobTypeAccepted(t *testing.T) {
// Same convention as Detect: an empty JobType is broadcast routing
// and must be accepted. The handler then errors at the next
// validation step (no S3 endpoints) rather than at the type check.
h := NewHandler(nil)
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
Job: &plugin_pb.JobSpec{}, // empty JobType
ClusterContext: &plugin_pb.ClusterContext{},
}, &recordingExecSender{})
require.Error(t, err)
assert.Contains(t, err.Error(), "no s3 servers", "validation flowed past the type check")
}
// ---------- sendSuccessCompletion ----------
func TestSendSuccessCompletion_EmitsCanonicalCompletion(t *testing.T) {
sender := &recordingExecSender{}
request := &plugin_pb.ExecuteJobRequest{
Job: &plugin_pb.JobSpec{JobId: "job-123"}, // empty JobType (broadcast routing)
}
require.NoError(t, sendSuccessCompletion(request, sender))
require.Len(t, sender.completed, 1, "exactly one completion must be sent")
c := sender.completed[0]
assert.Equal(t, jobType, c.JobType, "completion must use the canonical jobType, not request.Job.JobType")
assert.NotEmpty(t, c.JobType, "an empty JobType is dropped by the admin")
assert.Equal(t, "job-123", c.JobId, "completion must echo the original JobId")
assert.True(t, c.Success, "a clean replay is a success completion")
require.NotNil(t, c.Result)
assert.NotEmpty(t, c.Result.Summary, "admin UI surfaces a non-empty Result.Summary")
}
func TestSendSuccessCompletion_PropagatesSendError(t *testing.T) {
sentinel := errors.New("stream closed")
sender := &recordingExecSender{completedErr: sentinel}
request := &plugin_pb.ExecuteJobRequest{Job: &plugin_pb.JobSpec{JobId: "job-456"}}
err := sendSuccessCompletion(request, sender)
require.Error(t, err)
assert.ErrorIs(t, err, sentinel)
}
// ---------- lookupBucketsPath ----------
// stubFilerConfigClient implements filer_pb.SeaweedFilerClient for the
// GetFilerConfiguration call lookupBucketsPath relies on; methods that
// aren't named here would panic if called, which the tests rely on to
// keep the surface narrow.
type stubFilerConfigClient struct {
filer_pb.SeaweedFilerClient
resp *filer_pb.GetFilerConfigurationResponse
err error
}
func (c *stubFilerConfigClient) GetFilerConfiguration(_ context.Context, _ *filer_pb.GetFilerConfigurationRequest, _ ...grpc.CallOption) (*filer_pb.GetFilerConfigurationResponse, error) {
if c.err != nil {
return nil, c.err
}
return c.resp, nil
}
func TestLookupBucketsPath_PropagatesGRPCError(t *testing.T) {
// A failed config lookup must surface; without it, Execute would
// proceed to dial S3 with an empty buckets path and quietly never
// dispatch anything.
want := errors.New("filer down")
got, err := lookupBucketsPath(context.Background(), &stubFilerConfigClient{err: want})
assert.ErrorIs(t, err, want)
assert.Empty(t, got)
}
func TestLookupBucketsPath_UsesConfiguredDirBuckets(t *testing.T) {
// When the filer reports a non-empty DirBuckets, the worker honors
// it. Operators with a non-default layout (e.g. "/data/buckets")
// can't be routed to "/buckets".
got, err := lookupBucketsPath(context.Background(), &stubFilerConfigClient{
resp: &filer_pb.GetFilerConfigurationResponse{DirBuckets: "/data/buckets"},
})
require.NoError(t, err)
assert.Equal(t, "/data/buckets", got)
}
func TestLookupBucketsPath_EmptyDirBucketsFallsBackToDefault(t *testing.T) {
// Filer doesn't always populate DirBuckets (older configs); the
// helper falls back to the documented default "/buckets" rather
// than returning an empty path that would force-route to root.
got, err := lookupBucketsPath(context.Background(), &stubFilerConfigClient{
resp: &filer_pb.GetFilerConfigurationResponse{DirBuckets: ""},
})
require.NoError(t, err)
assert.Equal(t, "/buckets", got)
}

View File

@@ -0,0 +1,38 @@
package tasks
import (
"sync"
"github.com/seaweedfs/seaweedfs/weed/admin/config"
)
// TaskConfigSchema defines the schema for task configuration
type TaskConfigSchema struct {
config.Schema // Embed common schema functionality
TaskName string `json:"task_name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Icon string `json:"icon"`
}
// TaskConfigSchemaProvider is an interface for providing task configuration schemas
type TaskConfigSchemaProvider interface {
GetConfigSchema() *TaskConfigSchema
}
// schemaRegistry maintains a registry of schema providers by task type
type schemaRegistry struct {
providers map[string]TaskConfigSchemaProvider
mutex sync.RWMutex
}
var globalSchemaRegistry = &schemaRegistry{
providers: make(map[string]TaskConfigSchemaProvider),
}
// RegisterTaskConfigSchema registers a schema provider for a task type
func RegisterTaskConfigSchema(taskType string, provider TaskConfigSchemaProvider) {
globalSchemaRegistry.mutex.Lock()
defer globalSchemaRegistry.mutex.Unlock()
globalSchemaRegistry.providers[taskType] = provider
}

51
weed/worker/tasks/task.go Normal file
View File

@@ -0,0 +1,51 @@
package tasks
import (
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// UnsupportedTaskTypeError represents an error for unsupported task types
type UnsupportedTaskTypeError struct {
TaskType types.TaskType
}
// BaseTaskFactory provides common functionality for task factories
type BaseTaskFactory struct {
taskType types.TaskType
capabilities []string
description string
}
// NewBaseTaskFactory creates a new base task factory
func NewBaseTaskFactory(taskType types.TaskType, capabilities []string, description string) *BaseTaskFactory {
return &BaseTaskFactory{
taskType: taskType,
capabilities: capabilities,
description: description,
}
}
// Capabilities returns the capabilities required for this task type
func (f *BaseTaskFactory) Capabilities() []string {
return f.capabilities
}
// Description returns the description of this task type
func (f *BaseTaskFactory) Description() string {
return f.description
}
// ValidationError represents a parameter validation error
type ValidationError struct {
Field string
Message string
}
// getServerFromSources extracts the server address from unified sources
func getServerFromSources(sources []*worker_pb.TaskSource) string {
if len(sources) > 0 {
return sources[0].Node
}
return ""
}

View File

@@ -0,0 +1,225 @@
package tasks
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
)
// TaskLogHandler handles task log requests from admin server
type TaskLogHandler struct {
baseLogDir string
}
// NewTaskLogHandler creates a new task log handler
func NewTaskLogHandler(baseLogDir string) *TaskLogHandler {
if baseLogDir == "" {
baseLogDir = "/tmp/seaweedfs/task_logs"
}
// Best-effort ensure the base directory exists so reads don't fail due to missing dir
if err := os.MkdirAll(baseLogDir, 0755); err != nil {
glog.Warningf("Failed to create base task log directory %s: %v", baseLogDir, err)
}
return &TaskLogHandler{
baseLogDir: baseLogDir,
}
}
// HandleLogRequest processes a task log request and returns the response
func (h *TaskLogHandler) HandleLogRequest(request *worker_pb.TaskLogRequest) *worker_pb.TaskLogResponse {
response := &worker_pb.TaskLogResponse{
TaskId: request.TaskId,
WorkerId: request.WorkerId,
Success: false,
}
// Find the task log directory
logDir, err := h.findTaskLogDirectory(request.TaskId)
if err != nil {
response.ErrorMessage = fmt.Sprintf("Task log directory not found: %v", err)
glog.Warningf("Task log request failed for %s: %v", request.TaskId, err)
// Add diagnostic information to help debug the issue
response.LogEntries = []*worker_pb.TaskLogEntry{
{
Timestamp: time.Now().Unix(),
Level: "WARNING",
Message: fmt.Sprintf("Task logs not available: %v", err),
Fields: map[string]string{"source": "task_log_handler"},
},
{
Timestamp: time.Now().Unix(),
Level: "INFO",
Message: fmt.Sprintf("This usually means the task was never executed on this worker or logs were cleaned up. Base log directory: %s", h.baseLogDir),
Fields: map[string]string{"source": "task_log_handler"},
},
}
// response.Success remains false to indicate logs were not found
return response
}
// Read metadata if requested
if request.IncludeMetadata {
metadata, err := h.readTaskMetadata(logDir)
if err != nil {
response.ErrorMessage = fmt.Sprintf("Failed to read task metadata: %v", err)
glog.Warningf("Failed to read metadata for task %s: %v", request.TaskId, err)
return response
}
response.Metadata = metadata
}
// Read log entries
logEntries, err := h.readTaskLogEntries(logDir, request)
if err != nil {
response.ErrorMessage = fmt.Sprintf("Failed to read task logs: %v", err)
glog.Warningf("Failed to read logs for task %s: %v", request.TaskId, err)
return response
}
response.LogEntries = logEntries
response.Success = true
glog.V(1).Infof("Successfully retrieved %d log entries for task %s", len(logEntries), request.TaskId)
return response
}
// findTaskLogDirectory searches for the task log directory by task ID
func (h *TaskLogHandler) findTaskLogDirectory(taskID string) (string, error) {
entries, err := os.ReadDir(h.baseLogDir)
if err != nil {
return "", fmt.Errorf("failed to read base log directory %s: %w", h.baseLogDir, err)
}
// Look for directories that start with the task ID
var candidateDirs []string
for _, entry := range entries {
if entry.IsDir() {
candidateDirs = append(candidateDirs, entry.Name())
if strings.HasPrefix(entry.Name(), taskID+"_") {
return filepath.Join(h.baseLogDir, entry.Name()), nil
}
}
}
// Enhanced error message with diagnostic information
return "", fmt.Errorf("task log directory not found for task ID: %s (searched %d directories in %s, directories found: %v)",
taskID, len(candidateDirs), h.baseLogDir, candidateDirs)
}
// readTaskMetadata reads task metadata from the log directory
func (h *TaskLogHandler) readTaskMetadata(logDir string) (*worker_pb.TaskLogMetadata, error) {
metadata, err := GetTaskLogMetadata(logDir)
if err != nil {
return nil, err
}
// Convert to protobuf metadata
pbMetadata := &worker_pb.TaskLogMetadata{
TaskId: metadata.TaskID,
TaskType: metadata.TaskType,
WorkerId: metadata.WorkerID,
StartTime: metadata.StartTime.Unix(),
Status: metadata.Status,
Progress: float32(metadata.Progress),
VolumeId: metadata.VolumeID,
Server: metadata.Server,
Collection: metadata.Collection,
LogFilePath: metadata.LogFilePath,
CreatedAt: metadata.CreatedAt.Unix(),
CustomData: make(map[string]string),
}
// Set end time and duration if available
if metadata.EndTime != nil {
pbMetadata.EndTime = metadata.EndTime.Unix()
}
if metadata.Duration != nil {
pbMetadata.DurationMs = metadata.Duration.Milliseconds()
}
// Convert custom data
for key, value := range metadata.CustomData {
if strValue, ok := value.(string); ok {
pbMetadata.CustomData[key] = strValue
} else {
pbMetadata.CustomData[key] = fmt.Sprintf("%v", value)
}
}
return pbMetadata, nil
}
// readTaskLogEntries reads and filters log entries based on the request
func (h *TaskLogHandler) readTaskLogEntries(logDir string, request *worker_pb.TaskLogRequest) ([]*worker_pb.TaskLogEntry, error) {
entries, err := ReadTaskLogs(logDir)
if err != nil {
return nil, err
}
// Apply filters
var filteredEntries []TaskLogEntry
for _, entry := range entries {
// Filter by log level
if request.LogLevel != "" && !strings.EqualFold(entry.Level, request.LogLevel) {
continue
}
// Filter by time range
if request.StartTime > 0 && entry.Timestamp.Unix() < request.StartTime {
continue
}
if request.EndTime > 0 && entry.Timestamp.Unix() > request.EndTime {
continue
}
filteredEntries = append(filteredEntries, entry)
}
// Limit entries if requested
if request.MaxEntries > 0 && len(filteredEntries) > int(request.MaxEntries) {
// Take the most recent entries
start := len(filteredEntries) - int(request.MaxEntries)
filteredEntries = filteredEntries[start:]
}
// Convert to protobuf entries
var pbEntries []*worker_pb.TaskLogEntry
for _, entry := range filteredEntries {
pbEntry := &worker_pb.TaskLogEntry{
Timestamp: entry.Timestamp.Unix(),
Level: entry.Level,
Message: entry.Message,
Fields: make(map[string]string),
}
// Set progress if available
if entry.Progress != nil {
pbEntry.Progress = float32(*entry.Progress)
}
// Set status if available
if entry.Status != nil {
pbEntry.Status = *entry.Status
}
// Convert fields
for key, value := range entry.Fields {
if strValue, ok := value.(string); ok {
pbEntry.Fields[key] = strValue
} else {
pbEntry.Fields[key] = fmt.Sprintf("%v", value)
}
}
pbEntries = append(pbEntries, pbEntry)
}
return pbEntries, nil
}

View File

@@ -0,0 +1,444 @@
package tasks
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// TaskLogger provides file-based logging for individual tasks
type TaskLogger interface {
// Log methods
Info(message string, args ...interface{})
Warning(message string, args ...interface{})
Error(message string, args ...interface{})
Debug(message string, args ...interface{})
// Progress and status logging
LogProgress(progress float64, message string)
LogStatus(status string, message string)
// Structured logging
LogWithFields(level string, message string, fields map[string]interface{})
// Lifecycle
Sync() error
Close() error
GetLogDir() string
}
// LoggerProvider interface for tasks that support logging
type LoggerProvider interface {
InitializeTaskLogger(taskID string, workerID string, params types.TaskParams) error
GetTaskLogger() TaskLogger
}
// TaskLoggerConfig holds configuration for task logging
type TaskLoggerConfig struct {
BaseLogDir string
MaxTasks int // Maximum number of task logs to keep
MaxLogSizeMB int // Maximum log file size in MB
EnableConsole bool // Also log to console
}
// FileTaskLogger implements TaskLogger using file-based logging
type FileTaskLogger struct {
taskID string
taskType types.TaskType
workerID string
logDir string
logFile *os.File
mutex sync.Mutex
config TaskLoggerConfig
metadata *TaskLogMetadata
closed bool
}
// TaskLogMetadata contains metadata about the task execution
type TaskLogMetadata struct {
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
WorkerID string `json:"worker_id"`
StartTime time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time,omitempty"`
Duration *time.Duration `json:"duration,omitempty"`
Status string `json:"status"`
Progress float64 `json:"progress"`
VolumeID uint32 `json:"volume_id,omitempty"`
Server string `json:"server,omitempty"`
Collection string `json:"collection,omitempty"`
CustomData map[string]interface{} `json:"custom_data,omitempty"`
LogFilePath string `json:"log_file_path"`
CreatedAt time.Time `json:"created_at"`
}
// TaskLogEntry represents a single log entry
type TaskLogEntry struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Fields map[string]interface{} `json:"fields,omitempty"`
Progress *float64 `json:"progress,omitempty"`
Status *string `json:"status,omitempty"`
}
// DefaultTaskLoggerConfig returns default configuration
func DefaultTaskLoggerConfig() TaskLoggerConfig {
return TaskLoggerConfig{
BaseLogDir: "/data/task_logs", // Use persistent data directory
MaxTasks: 100, // Keep last 100 task logs
MaxLogSizeMB: 10,
EnableConsole: true,
}
}
// NewTaskLogger creates a new file-based task logger
func NewTaskLogger(taskID string, taskType types.TaskType, workerID string, params types.TaskParams, config TaskLoggerConfig) (TaskLogger, error) {
// Create unique directory name with timestamp
timestamp := time.Now().Format("20060102_150405")
dirName := fmt.Sprintf("%s_%s_%s_%s", taskID, taskType, workerID, timestamp)
logDir := filepath.Join(config.BaseLogDir, dirName)
// Create log directory
if err := os.MkdirAll(logDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create log directory %s: %w", logDir, err)
}
// Create log file
logFilePath := filepath.Join(logDir, "task.log")
logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, fmt.Errorf("failed to create log file %s: %w", logFilePath, err)
}
// Create metadata
metadata := &TaskLogMetadata{
TaskID: taskID,
TaskType: string(taskType),
WorkerID: workerID,
StartTime: time.Now(),
Status: "started",
Progress: 0.0,
VolumeID: params.VolumeID,
Server: getServerFromSources(params.TypedParams.Sources),
Collection: params.Collection,
CustomData: make(map[string]interface{}),
LogFilePath: logFilePath,
CreatedAt: time.Now(),
}
logger := &FileTaskLogger{
taskID: taskID,
taskType: taskType,
workerID: workerID,
logDir: logDir,
logFile: logFile,
config: config,
metadata: metadata,
closed: false,
}
// Write initial log entry
logger.Info("Task logger initialized for %s (type: %s, worker: %s)", taskID, taskType, workerID)
logger.LogWithFields("INFO", "Task parameters", map[string]interface{}{
"volume_id": params.VolumeID,
"server": getServerFromSources(params.TypedParams.Sources),
"collection": params.Collection,
})
// Save initial metadata
if err := logger.saveMetadata(); err != nil {
glog.Warningf("Failed to save initial task metadata: %v", err)
}
// Clean up old task logs
go logger.cleanupOldLogs()
return logger, nil
}
// Info logs an info message
func (l *FileTaskLogger) Info(message string, args ...interface{}) {
l.log("INFO", message, args...)
}
// Warning logs a warning message
func (l *FileTaskLogger) Warning(message string, args ...interface{}) {
l.log("WARNING", message, args...)
}
// Error logs an error message
func (l *FileTaskLogger) Error(message string, args ...interface{}) {
l.log("ERROR", message, args...)
}
// Debug logs a debug message
func (l *FileTaskLogger) Debug(message string, args ...interface{}) {
l.log("DEBUG", message, args...)
}
// LogProgress logs task progress
func (l *FileTaskLogger) LogProgress(progress float64, message string) {
l.mutex.Lock()
l.metadata.Progress = progress
l.mutex.Unlock()
entry := TaskLogEntry{
Timestamp: time.Now(),
Level: "INFO",
Message: message,
Progress: &progress,
}
l.writeLogEntry(entry)
// Update metadata with new progress
if err := l.saveMetadata(); err != nil {
glog.Warningf("Failed to save initial task metadata: %v", err)
}
}
// LogStatus logs task status change
func (l *FileTaskLogger) LogStatus(status string, message string) {
l.mutex.Lock()
l.metadata.Status = status
l.mutex.Unlock()
entry := TaskLogEntry{
Timestamp: time.Now(),
Level: "INFO",
Message: message,
Status: &status,
}
l.writeLogEntry(entry)
// Update metadata with new status
if err := l.saveMetadata(); err != nil {
glog.Warningf("Failed to update metadata with new status: %v", err)
}
}
// LogWithFields logs a message with structured fields
func (l *FileTaskLogger) LogWithFields(level string, message string, fields map[string]interface{}) {
entry := TaskLogEntry{
Timestamp: time.Now(),
Level: level,
Message: message,
Fields: fields,
}
l.writeLogEntry(entry)
}
// Sync flushes buffered data to disk
func (l *FileTaskLogger) Sync() error {
l.mutex.Lock()
defer l.mutex.Unlock()
if l.logFile != nil {
return l.logFile.Sync()
}
return nil
}
// Close closes the logger and finalizes metadata
func (l *FileTaskLogger) Close() error {
l.Info("Task logger closed for %s", l.taskID)
l.mutex.Lock()
defer l.mutex.Unlock()
if l.closed {
return nil
}
// Finalize metadata
endTime := time.Now()
duration := endTime.Sub(l.metadata.StartTime)
l.metadata.EndTime = &endTime
l.metadata.Duration = &duration
if l.metadata.Status == "started" {
l.metadata.Status = "completed"
}
// Save final metadata
if err := l.saveMetadata(); err != nil {
glog.Warningf("Failed to save final task metadata: %v", err)
}
// Close log file
if l.logFile != nil {
if err := l.logFile.Close(); err != nil {
return fmt.Errorf("failed to close log file: %w", err)
}
}
l.closed = true
return nil
}
// GetLogDir returns the log directory path
func (l *FileTaskLogger) GetLogDir() string {
return l.logDir
}
// log is the internal logging method
func (l *FileTaskLogger) log(level string, message string, args ...interface{}) {
formattedMessage := fmt.Sprintf(message, args...)
entry := TaskLogEntry{
Timestamp: time.Now(),
Level: level,
Message: formattedMessage,
}
l.writeLogEntry(entry)
}
// writeLogEntry writes a log entry to the file
func (l *FileTaskLogger) writeLogEntry(entry TaskLogEntry) {
l.mutex.Lock()
defer l.mutex.Unlock()
if l.closed || l.logFile == nil {
return
}
// Format as JSON line
jsonData, err := json.Marshal(entry)
if err != nil {
glog.Errorf("Failed to marshal log entry: %v", err)
return
}
// Write to file
if _, err := l.logFile.WriteString(string(jsonData) + "\n"); err != nil {
glog.Errorf("Failed to write log entry: %v", err)
return
}
// Only forward errors and warnings to glog; routine task logs stay in the task log file.
if l.config.EnableConsole {
formattedMsg := fmt.Sprintf("[TASK-%s] %s: %s", l.taskID, entry.Level, entry.Message)
switch entry.Level {
case "ERROR":
glog.ErrorDepth(4, formattedMsg)
case "WARNING":
glog.WarningDepth(4, formattedMsg)
}
}
}
// saveMetadata saves task metadata to file
func (l *FileTaskLogger) saveMetadata() error {
metadataPath := filepath.Join(l.logDir, "metadata.json")
data, err := json.MarshalIndent(l.metadata, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
return os.WriteFile(metadataPath, data, 0644)
}
// cleanupOldLogs removes old task log directories to maintain the limit
func (l *FileTaskLogger) cleanupOldLogs() {
baseDir := l.config.BaseLogDir
entries, err := os.ReadDir(baseDir)
if err != nil {
glog.Warningf("Failed to read log directory %s: %v", baseDir, err)
return
}
// Filter for directories only
var dirs []os.DirEntry
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, entry)
}
}
// If we're under the limit, nothing to clean
if len(dirs) <= l.config.MaxTasks {
return
}
// Sort by modification time (oldest first)
sort.Slice(dirs, func(i, j int) bool {
infoI, errI := dirs[i].Info()
infoJ, errJ := dirs[j].Info()
if errI != nil || errJ != nil {
return false
}
return infoI.ModTime().Before(infoJ.ModTime())
})
// Remove oldest directories
numToRemove := len(dirs) - l.config.MaxTasks
for i := 0; i < numToRemove; i++ {
dirPath := filepath.Join(baseDir, dirs[i].Name())
if err := os.RemoveAll(dirPath); err != nil {
glog.Warningf("Failed to remove old log directory %s: %v", dirPath, err)
} else {
glog.V(1).Infof("Cleaned up old task log directory: %s", dirPath)
}
}
glog.V(1).Infof("Task log cleanup completed: removed %d old directories", numToRemove)
}
// GetTaskLogMetadata reads metadata from a task log directory
func GetTaskLogMetadata(logDir string) (*TaskLogMetadata, error) {
metadataPath := filepath.Join(logDir, "metadata.json")
data, err := os.ReadFile(metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read metadata file: %w", err)
}
var metadata TaskLogMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
}
return &metadata, nil
}
// ReadTaskLogs reads all log entries from a task log file
func ReadTaskLogs(logDir string) ([]TaskLogEntry, error) {
logPath := filepath.Join(logDir, "task.log")
file, err := os.Open(logPath)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err)
}
defer file.Close()
var entries []TaskLogEntry
decoder := json.NewDecoder(file)
for {
var entry TaskLogEntry
if err := decoder.Decode(&entry); err != nil {
if err == io.EOF {
break
}
// If we fail to decode an entry, it might be a partial write at the end of the file
// Return what we have so far instead of failing the entire request
glog.V(1).Infof("Failed to decode log entry in %s: %v (returning %d partial logs)", logPath, err, len(entries))
break
}
entries = append(entries, entry)
}
return entries, nil
}

View File

@@ -0,0 +1,79 @@
package tasks
import (
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// BaseUIProvider provides common UIProvider functionality for all tasks
type BaseUIProvider struct {
taskType types.TaskType
displayName string
description string
icon string
schemaFunc func() *TaskConfigSchema
configFunc func() types.TaskConfig
applyTaskPolicyFunc func(policy *worker_pb.TaskPolicy) error
applyTaskConfigFunc func(config types.TaskConfig) error
}
// NewBaseUIProvider creates a new base UI provider
func NewBaseUIProvider(
taskType types.TaskType,
displayName string,
description string,
icon string,
schemaFunc func() *TaskConfigSchema,
configFunc func() types.TaskConfig,
applyTaskPolicyFunc func(policy *worker_pb.TaskPolicy) error,
applyTaskConfigFunc func(config types.TaskConfig) error,
) *BaseUIProvider {
return &BaseUIProvider{
taskType: taskType,
displayName: displayName,
description: description,
icon: icon,
schemaFunc: schemaFunc,
configFunc: configFunc,
applyTaskPolicyFunc: applyTaskPolicyFunc,
applyTaskConfigFunc: applyTaskConfigFunc,
}
}
// GetTaskType returns the task type
func (ui *BaseUIProvider) GetTaskType() types.TaskType {
return ui.taskType
}
// GetDisplayName returns the human-readable name
func (ui *BaseUIProvider) GetDisplayName() string {
return ui.displayName
}
// GetDescription returns a description of what this task does
func (ui *BaseUIProvider) GetDescription() string {
return ui.description
}
// GetIcon returns the icon CSS class for this task type
func (ui *BaseUIProvider) GetIcon() string {
return ui.icon
}
// GetCurrentConfig returns the current configuration as TaskConfig
func (ui *BaseUIProvider) GetCurrentConfig() types.TaskConfig {
return ui.configFunc()
}
// ApplyTaskPolicy applies protobuf TaskPolicy configuration
func (ui *BaseUIProvider) ApplyTaskPolicy(policy *worker_pb.TaskPolicy) error {
return ui.applyTaskPolicyFunc(policy)
}
// ApplyTaskConfig applies TaskConfig interface configuration
func (ui *BaseUIProvider) ApplyTaskConfig(config types.TaskConfig) error {
return ui.applyTaskConfigFunc(config)
}
// RegisterUIFunc provides a common registration function signature
type RegisterUIFunc[D, S any] func(uiRegistry *types.UIRegistry, detector D, scheduler S)

View File

@@ -0,0 +1,23 @@
package util
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/pb"
)
// ResolveServerAddress resolves a server ID to its network address using the active topology
func ResolveServerAddress(serverID string, activeTopology *topology.ActiveTopology) (string, error) {
if activeTopology == nil {
return "", fmt.Errorf("topology not available")
}
allNodes := activeTopology.GetAllNodes()
nodeInfo, exists := allNodes[serverID]
if !exists {
return "", fmt.Errorf("server %s not found in topology", serverID)
}
// Note: We use NewServerAddressFromDataNode here because it encodes the GRPC port into the server address
// if it is non-zero, avoiding the default +10000 GRPC port offset.
return string(pb.NewServerAddressFromDataNode(nodeInfo)), nil
}

View File

@@ -0,0 +1,183 @@
package vacuum
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/admin/config"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
)
// Config extends BaseConfig with vacuum-specific settings
type Config struct {
base.BaseConfig
GarbageThreshold float64 `json:"garbage_threshold"`
MinVolumeAgeSeconds int `json:"min_volume_age_seconds"`
}
// NewDefaultConfig creates a new default vacuum configuration
func NewDefaultConfig() *Config {
return &Config{
BaseConfig: base.BaseConfig{
Enabled: true,
ScanIntervalSeconds: 2 * 60 * 60, // 2 hours
MaxConcurrent: 2,
},
GarbageThreshold: 0.3, // 30%
MinVolumeAgeSeconds: 24 * 60 * 60, // 24 hours
}
}
// ToTaskPolicy converts configuration to a TaskPolicy protobuf message
func (c *Config) ToTaskPolicy() *worker_pb.TaskPolicy {
return &worker_pb.TaskPolicy{
Enabled: c.Enabled,
MaxConcurrent: int32(c.MaxConcurrent),
RepeatIntervalSeconds: int32(c.ScanIntervalSeconds),
CheckIntervalSeconds: int32(c.ScanIntervalSeconds),
TaskConfig: &worker_pb.TaskPolicy_VacuumConfig{
VacuumConfig: &worker_pb.VacuumTaskConfig{
GarbageThreshold: float64(c.GarbageThreshold),
MinVolumeAgeHours: int32((c.MinVolumeAgeSeconds + 3599) / 3600), // round up so sub-hour values don't become 0
},
},
}
}
// FromTaskPolicy loads configuration from a TaskPolicy protobuf message
func (c *Config) FromTaskPolicy(policy *worker_pb.TaskPolicy) error {
if policy == nil {
return fmt.Errorf("policy is nil")
}
// Set general TaskPolicy fields
c.Enabled = policy.Enabled
c.MaxConcurrent = int(policy.MaxConcurrent)
c.ScanIntervalSeconds = int(policy.RepeatIntervalSeconds) // Direct seconds-to-seconds mapping
// Set vacuum-specific fields from the task config
if vacuumConfig := policy.GetVacuumConfig(); vacuumConfig != nil {
c.GarbageThreshold = float64(vacuumConfig.GarbageThreshold)
c.MinVolumeAgeSeconds = int(vacuumConfig.MinVolumeAgeHours * 3600) // Convert hours to seconds
}
return nil
}
// LoadConfigFromPersistence loads configuration from the persistence layer if available
func LoadConfigFromPersistence(configPersistence interface{}) *Config {
config := NewDefaultConfig()
// Try to load from persistence if available
if persistence, ok := configPersistence.(interface {
LoadVacuumTaskPolicy() (*worker_pb.TaskPolicy, error)
}); ok {
policy, err := persistence.LoadVacuumTaskPolicy()
switch {
case err != nil:
glog.Warningf("Could not read the persisted vacuum configuration, falling back to defaults: %v", err)
case policy == nil:
glog.V(1).Infof("No vacuum configuration persisted yet, using defaults")
default:
if err := config.FromTaskPolicy(policy); err != nil {
glog.Warningf("Could not apply the persisted vacuum configuration, falling back to defaults: %v", err)
} else {
glog.V(1).Infof("Loaded vacuum configuration from persistence")
return config
}
}
} else if configPersistence != nil {
// A store was handed in but does not expose the accessor, so the persisted
// settings are silently ignored - always a wiring bug, never a normal state.
glog.Warningf("%T cannot provide the persisted vacuum configuration: it has no LoadVacuumTaskPolicy() method, "+
"so the compiled-in defaults are used and any saved vacuum settings are ignored", configPersistence)
}
glog.V(1).Infof("Using default vacuum configuration")
return config
}
// GetConfigSpec returns the configuration schema for vacuum tasks
func GetConfigSpec() base.ConfigSpec {
return base.ConfigSpec{
Fields: []*config.Field{
{
Name: "enabled",
JSONName: "enabled",
Type: config.FieldTypeBool,
DefaultValue: true,
Required: false,
DisplayName: "Enable Vacuum Tasks",
Description: "Whether vacuum tasks should be automatically created",
HelpText: "Toggle this to enable or disable automatic vacuum task generation",
InputType: "checkbox",
CSSClasses: "form-check-input",
},
{
Name: "scan_interval_seconds",
JSONName: "scan_interval_seconds",
Type: config.FieldTypeInterval,
DefaultValue: 2 * 60 * 60,
MinValue: 10 * 60,
MaxValue: 24 * 60 * 60,
Required: true,
DisplayName: "Scan Interval",
Description: "How often to scan for volumes needing vacuum",
HelpText: "The system will check for volumes that need vacuuming at this interval",
Placeholder: "2",
Unit: config.UnitHours,
InputType: "interval",
CSSClasses: "form-control",
},
{
Name: "max_concurrent",
JSONName: "max_concurrent",
Type: config.FieldTypeInt,
DefaultValue: 2,
MinValue: 1,
MaxValue: 10,
Required: true,
DisplayName: "Max Concurrent Tasks",
Description: "Maximum number of vacuum tasks that can run simultaneously",
HelpText: "Limits the number of vacuum operations running at the same time to control system load",
Placeholder: "2 (default)",
Unit: config.UnitCount,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "garbage_threshold",
JSONName: "garbage_threshold",
Type: config.FieldTypeFloat,
DefaultValue: 0.3,
MinValue: 0.0,
MaxValue: 1.0,
Required: true,
DisplayName: "Garbage Percentage Threshold",
Description: "Trigger vacuum when garbage ratio exceeds this percentage",
HelpText: "Volumes with more deleted content than this threshold will be vacuumed",
Placeholder: "0.30 (30%)",
Unit: config.UnitNone,
InputType: "number",
CSSClasses: "form-control",
},
{
Name: "min_volume_age_seconds",
JSONName: "min_volume_age_seconds",
Type: config.FieldTypeInterval,
DefaultValue: 24 * 60 * 60,
MinValue: 1 * 60 * 60,
MaxValue: 7 * 24 * 60 * 60,
Required: true,
DisplayName: "Minimum Volume Age",
Description: "Only vacuum volumes older than this duration",
HelpText: "Prevents vacuuming of recently created volumes that may still be actively written to",
Placeholder: "24",
Unit: config.UnitHours,
InputType: "interval",
CSSClasses: "form-control",
},
},
}
}

View File

@@ -0,0 +1,75 @@
package vacuum
import (
"errors"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
)
type stubVacuumStore struct {
policy *worker_pb.TaskPolicy
err error
}
func (s *stubVacuumStore) LoadVacuumTaskPolicy() (*worker_pb.TaskPolicy, error) {
return s.policy, s.err
}
// wrongShapedStore is what the maintenance manager used to be handed: a non-nil value that
// does not satisfy the accessor the loader asserts on. It has to keep falling back to the
// defaults, but no longer silently: a fallback the operator cannot see reads as a disabled task
// that keeps running.
type wrongShapedStore struct{}
func (wrongShapedStore) SomethingElse() {}
func TestLoadConfigFromPersistenceUsesPersistedPolicy(t *testing.T) {
persisted := NewDefaultConfig()
persisted.Enabled = false
persisted.GarbageThreshold = 0.75
persisted.MaxConcurrent = 5
loaded := LoadConfigFromPersistence(&stubVacuumStore{policy: persisted.ToTaskPolicy()})
if loaded == nil {
t.Fatal("LoadConfigFromPersistence returned nil")
}
if loaded.Enabled {
t.Error("enabled = true, want the persisted false")
}
if loaded.GarbageThreshold != 0.75 {
t.Errorf("garbage threshold = %v, want the persisted 0.75", loaded.GarbageThreshold)
}
if loaded.MaxConcurrent != 5 {
t.Errorf("max concurrent = %d, want the persisted 5", loaded.MaxConcurrent)
}
}
func TestLoadConfigFromPersistenceFallsBackToDefaults(t *testing.T) {
defaults := NewDefaultConfig()
cases := map[string]interface{}{
"no store configured": nil,
"store without the value": &stubVacuumStore{},
"store that errors": &stubVacuumStore{err: errors.New("disk on fire")},
"store of the wrong type": wrongShapedStore{},
}
for name, store := range cases {
t.Run(name, func(t *testing.T) {
loaded := LoadConfigFromPersistence(store)
if loaded == nil {
t.Fatal("LoadConfigFromPersistence returned nil")
}
if !loaded.Enabled {
t.Error("enabled = false, want the compiled-in default true")
}
if loaded.GarbageThreshold != defaults.GarbageThreshold {
t.Errorf("garbage threshold = %v, want the default %v", loaded.GarbageThreshold, defaults.GarbageThreshold)
}
if loaded.ScanIntervalSeconds != defaults.ScanIntervalSeconds {
t.Errorf("scan interval = %d, want the default %d", loaded.ScanIntervalSeconds, defaults.ScanIntervalSeconds)
}
})
}
}

View File

@@ -0,0 +1,227 @@
package vacuum
import (
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/util"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Detection implements the detection logic for vacuum tasks
func Detection(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig) ([]*types.TaskDetectionResult, error) {
if !config.IsEnabled() {
return nil, nil
}
vacuumConfig := config.(*Config)
minVolumeAge := time.Duration(vacuumConfig.MinVolumeAgeSeconds) * time.Second
type volumeBucket struct {
replicas []*types.VolumeHealthMetrics
}
buckets := make(map[uint32]*volumeBucket)
order := make([]uint32, 0)
for _, m := range metrics {
if m == nil {
continue
}
b, ok := buckets[m.VolumeID]
if !ok {
b = &volumeBucket{}
buckets[m.VolumeID] = b
order = append(order, m.VolumeID)
}
b.replicas = append(b.replicas, m)
}
var results []*types.TaskDetectionResult
skippedDueToGarbage := 0
skippedDueToAge := 0
debugCount := 0
for _, vid := range order {
replicas := buckets[vid].replicas
// Pick the replica with the highest garbage ratio that also satisfies
// the minimum-age gate as the "primary" used for priority + reason.
// Master built-in vacuum filters per-replica again inside
// batchVacuumVolumeCheck, so it's fine if only some replicas are
// above threshold here — the worker's checkVacuumEligibility will
// filter again at execute time.
primary := pickPrimaryReplica(replicas, vacuumConfig.GarbageThreshold, minVolumeAge)
if primary == nil {
recordSkipReasons(replicas, vacuumConfig.GarbageThreshold, minVolumeAge, &skippedDueToGarbage, &skippedDueToAge, &debugCount)
continue
}
priority := types.TaskPriorityNormal
if primary.GarbageRatio > 0.6 {
priority = types.TaskPriorityHigh
}
taskID := fmt.Sprintf("vacuum_vol_%d_%d", vid, time.Now().Unix())
result := &types.TaskDetectionResult{
TaskID: taskID,
TaskType: types.TaskTypeVacuum,
VolumeID: vid,
Server: primary.Server,
Collection: primary.Collection,
Priority: priority,
Reason: "Volume has excessive garbage requiring vacuum",
ScheduleAt: time.Now(),
}
// Check if ANY task already exists in ActiveTopology for this volume
if clusterInfo != nil && clusterInfo.ActiveTopology != nil {
if clusterInfo.ActiveTopology.HasAnyTask(vid) {
glog.V(2).Infof("VACUUM: Skipping volume %d, task already exists in ActiveTopology", vid)
continue
}
}
result.TypedParams = createVacuumTaskParams(result, replicas, vacuumConfig, clusterInfo)
if result.TypedParams != nil {
results = append(results, result)
}
}
if len(results) == 0 && len(metrics) > 0 {
totalVolumes := len(metrics)
glog.V(1).Infof("VACUUM: No tasks created for %d volume replicas across %d volumes. Threshold=%.2f%%, MinAge=%s. Skipped: %d (garbage<threshold), %d (age<minimum)",
totalVolumes, len(buckets), vacuumConfig.GarbageThreshold*100, minVolumeAge, skippedDueToGarbage, skippedDueToAge)
for i, metric := range metrics {
if i >= 3 {
break
}
glog.V(1).Infof("VACUUM: Volume %d on %s: garbage=%.2f%% (need ≥%.2f%%), age=%s (need ≥%s)",
metric.VolumeID, metric.Server, metric.GarbageRatio*100, vacuumConfig.GarbageThreshold*100,
metric.Age.Truncate(time.Minute), minVolumeAge.Truncate(time.Minute))
}
}
return results, nil
}
// pickPrimaryReplica returns the eligible replica with the highest garbage
// ratio. Returns nil if no replica satisfies both gates.
func pickPrimaryReplica(replicas []*types.VolumeHealthMetrics, garbageThreshold float64, minAge time.Duration) *types.VolumeHealthMetrics {
var best *types.VolumeHealthMetrics
for _, m := range replicas {
if m.GarbageRatio < garbageThreshold {
continue
}
if m.Age < minAge {
continue
}
if best == nil || m.GarbageRatio > best.GarbageRatio {
best = m
}
}
return best
}
// recordSkipReasons tallies skip counters for debug logging when no
// replica of a volume qualified for vacuum.
func recordSkipReasons(replicas []*types.VolumeHealthMetrics, garbageThreshold float64, minAge time.Duration, garbageSkip, ageSkip, debugCount *int) {
for _, m := range replicas {
if *debugCount >= 5 {
return
}
if m.GarbageRatio < garbageThreshold {
*garbageSkip++
}
if m.Age < minAge {
*ageSkip++
}
*debugCount++
}
}
// createVacuumTaskParams creates typed parameters for a vacuum task whose
// Sources list contains every replica of the volume. Worker-side
// performVacuum iterates over Sources and vacuums each replica.
func createVacuumTaskParams(task *types.TaskDetectionResult, replicas []*types.VolumeHealthMetrics, vacuumConfig *Config, clusterInfo *types.ClusterInfo) *worker_pb.TaskParams {
garbageThreshold := 0.3
verifyChecksum := true
batchSize := int32(1000)
workingDir := ""
if vacuumConfig != nil {
garbageThreshold = vacuumConfig.GarbageThreshold
}
if clusterInfo == nil || clusterInfo.ActiveTopology == nil {
glog.Errorf("Topology not available for vacuum task on volume %d, skipping", task.VolumeID)
return nil
}
sources := make([]*worker_pb.TaskSource, 0, len(replicas))
seen := make(map[string]struct{}, len(replicas))
for _, m := range replicas {
address, err := util.ResolveServerAddress(m.Server, clusterInfo.ActiveTopology)
if err != nil {
glog.Warningf("Failed to resolve address for server %s for vacuum task on volume %d, dropping replica: %v", m.Server, task.VolumeID, err)
continue
}
if _, ok := seen[address]; ok {
continue
}
seen[address] = struct{}{}
sources = append(sources, &worker_pb.TaskSource{
Node: address,
VolumeId: task.VolumeID,
EstimatedSize: m.Size,
DataCenter: m.DataCenter,
Rack: m.Rack,
})
}
if len(sources) == 0 {
glog.Errorf("No resolvable replicas for vacuum task on volume %d, skipping", task.VolumeID)
return nil
}
// Use the primary replica (matches task.Server) for the canonical size
// recorded in TaskParams. Master built-in keeps a single volumeSizeLimit
// here so timeouts are stable across replicas.
canonical := primaryReplicaByServer(replicas, task.Server)
var canonicalSize uint64
if canonical != nil {
canonicalSize = canonical.Size
}
return &worker_pb.TaskParams{
TaskId: task.TaskID,
VolumeId: task.VolumeID,
Collection: task.Collection,
VolumeSize: canonicalSize,
Sources: sources,
TaskParams: &worker_pb.TaskParams_VacuumParams{
VacuumParams: &worker_pb.VacuumTaskParams{
GarbageThreshold: garbageThreshold,
ForceVacuum: false,
BatchSize: batchSize,
WorkingDir: workingDir,
VerifyChecksum: verifyChecksum,
},
},
}
}
func primaryReplicaByServer(replicas []*types.VolumeHealthMetrics, server string) *types.VolumeHealthMetrics {
for _, m := range replicas {
if m.Server == server {
return m
}
}
if len(replicas) > 0 {
return replicas[0]
}
return nil
}

View File

@@ -0,0 +1,611 @@
package vacuum
import (
"context"
"fmt"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
const (
defaultVacuumTaskBatchSize = int32(1000)
DefaultMaxExecutionConcurrency = int32(2)
)
func init() {
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
JobType: "vacuum",
Category: pluginworker.CategoryDefault,
Aliases: []string{"vol.vacuum", "volume.vacuum"},
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
return NewVacuumHandler(opts.GrpcDialOption, int32(opts.MaxExecute)), nil
},
})
}
// VacuumHandler is the plugin job handler for vacuum job type.
type VacuumHandler struct {
grpcDialOption grpc.DialOption
maxExecutionConcurrency int32
}
// NewVacuumHandler creates a VacuumHandler with the given gRPC dial option and
// maximum execution concurrency. When maxExecutionConcurrency is zero or
// negative, DefaultMaxExecutionConcurrency is used as the fallback.
func NewVacuumHandler(grpcDialOption grpc.DialOption, maxExecutionConcurrency int32) *VacuumHandler {
return &VacuumHandler{grpcDialOption: grpcDialOption, maxExecutionConcurrency: maxExecutionConcurrency}
}
// Capability returns the job type capability for the vacuum handler.
// MaxExecutionConcurrency reflects the value passed at construction time,
// falling back to DefaultMaxExecutionConcurrency when unset.
func (h *VacuumHandler) Capability() *plugin_pb.JobTypeCapability {
maxExec := h.maxExecutionConcurrency
if maxExec <= 0 {
maxExec = DefaultMaxExecutionConcurrency
}
return &plugin_pb.JobTypeCapability{
JobType: "vacuum",
CanDetect: true,
CanExecute: true,
MaxDetectionConcurrency: 1,
MaxExecutionConcurrency: maxExec,
DisplayName: "Volume Vacuum",
Description: "Reclaims disk space by removing deleted files from volumes",
Weight: 60,
}
}
func (h *VacuumHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
return &plugin_pb.JobTypeDescriptor{
JobType: "vacuum",
DisplayName: "Volume Vacuum",
Description: "Detect and vacuum volumes with high garbage ratio",
Icon: "fas fa-broom",
DescriptorVersion: 1,
AdminConfigForm: &plugin_pb.ConfigForm{
FormId: "vacuum-admin",
Title: "Vacuum Admin Config",
Description: "Admin-side controls for vacuum detection scope.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "scope",
Title: "Scope",
Description: "Optional filter to restrict detection.",
Fields: []*plugin_pb.ConfigField{
{
Name: "collection_filter",
Label: "Collection Filter",
Description: "Filter collections for vacuum detection. Use ALL_COLLECTIONS (default) to treat all volumes as one pool, EACH_COLLECTION to run detection separately per collection, or a comma-separated list of names, wildcards, or regex patterns.",
Placeholder: "ALL_COLLECTIONS",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "volume_state",
Label: "Volume State Filter",
Description: "Filter volumes by state: ALL (default), ACTIVE (writable volumes below size limit), or FULL (read-only volumes above size limit).",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_SELECT,
Options: []*plugin_pb.ConfigOption{
{Value: string(pluginworker.VolumeStateAll), Label: "All Volumes"},
{Value: string(pluginworker.VolumeStateActive), Label: "Active (writable)"},
{Value: string(pluginworker.VolumeStateFull), Label: "Full (read-only)"},
},
},
{
Name: "data_center_filter",
Label: "Data Center Filter",
Description: "Only vacuum volumes in matching data centers (comma-separated, wildcards supported). Leave empty for all.",
Placeholder: "all data centers",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "rack_filter",
Label: "Rack Filter",
Description: "Only vacuum volumes on matching racks (comma-separated, wildcards supported). Leave empty for all.",
Placeholder: "all racks",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
{
Name: "node_filter",
Label: "Node Filter",
Description: "Only vacuum volumes on matching nodes (comma-separated, wildcards supported). Leave empty for all.",
Placeholder: "all nodes",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
},
},
},
DefaultValues: map[string]*plugin_pb.ConfigValue{
"collection_filter": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
},
"volume_state": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: string(pluginworker.VolumeStateAll)},
},
"data_center_filter": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
},
"rack_filter": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
},
"node_filter": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
},
},
},
WorkerConfigForm: &plugin_pb.ConfigForm{
FormId: "vacuum-worker",
Title: "Vacuum Worker Config",
Description: "Worker-side vacuum thresholds.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "thresholds",
Title: "Thresholds",
Description: "Detection thresholds and timing constraints.",
Fields: []*plugin_pb.ConfigField{
{
Name: "garbage_threshold",
Label: "Garbage Threshold",
Description: "Detect volumes with garbage ratio >= threshold.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_DOUBLE,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
Required: true,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0}},
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 1}},
},
},
},
},
DefaultValues: map[string]*plugin_pb.ConfigValue{
"garbage_threshold": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.3},
},
},
},
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
Enabled: true,
DetectionIntervalMinutes: 17,
DetectionTimeoutSeconds: 120,
MaxJobsPerDetection: 200,
GlobalExecutionConcurrency: 16,
PerWorkerExecutionConcurrency: 4,
RetryLimit: 1,
RetryBackoffSeconds: 10,
JobTypeMaxRuntimeSeconds: 1800,
ExecutionTimeoutSeconds: 1800,
},
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
"garbage_threshold": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.3},
},
},
}
}
func (h *VacuumHandler) 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 != "vacuum" {
return fmt.Errorf("job type %q is not handled by vacuum worker", request.JobType)
}
workerConfig := deriveVacuumConfig(request.GetWorkerConfigValues())
collectionFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "collection_filter", ""))
masters := make([]string, 0)
if request.ClusterContext != nil {
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
}
metrics, activeTopology, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
if err != nil {
return err
}
volState := pluginworker.VolumeState(strings.ToUpper(strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "volume_state", string(pluginworker.VolumeStateAll)))))
metrics = pluginworker.FilterMetricsByVolumeState(metrics, volState)
dataCenterFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "data_center_filter", ""))
rackFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "rack_filter", ""))
nodeFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "node_filter", ""))
if dataCenterFilter != "" || rackFilter != "" || nodeFilter != "" {
metrics = pluginworker.FilterMetricsByLocation(metrics, dataCenterFilter, rackFilter, nodeFilter)
}
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
results, err := Detection(metrics, clusterInfo, workerConfig)
if err != nil {
return err
}
if traceErr := emitVacuumDetectionDecisionTrace(sender, metrics, workerConfig, results); traceErr != nil {
glog.Warningf("Plugin worker failed to emit vacuum detection trace: %v", traceErr)
}
maxResults := int(request.MaxResults)
hasMore := false
if maxResults > 0 && len(results) > maxResults {
hasMore = true
results = results[:maxResults]
}
proposals := make([]*plugin_pb.JobProposal, 0, len(results))
for _, result := range results {
proposal, proposalErr := buildVacuumProposal(result)
if proposalErr != nil {
glog.Warningf("Plugin worker skip invalid vacuum proposal: %v", proposalErr)
continue
}
proposals = append(proposals, proposal)
}
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
JobType: "vacuum",
Proposals: proposals,
HasMore: hasMore,
}); err != nil {
return err
}
return sender.SendComplete(&plugin_pb.DetectionComplete{
JobType: "vacuum",
Success: true,
TotalProposals: int32(len(proposals)),
})
}
func emitVacuumDetectionDecisionTrace(
sender pluginworker.DetectionSender,
metrics []*workertypes.VolumeHealthMetrics,
workerConfig *Config,
results []*workertypes.TaskDetectionResult,
) error {
if sender == nil || workerConfig == nil {
return nil
}
totalVolumes := len(metrics)
skippedDueToGarbage := 0
for _, metric := range metrics {
if metric == nil {
continue
}
if metric.GarbageRatio >= workerConfig.GarbageThreshold {
continue
}
skippedDueToGarbage++
}
summaryMessage := ""
summaryStage := "decision_summary"
if len(results) == 0 {
summaryMessage = fmt.Sprintf(
"VACUUM: No tasks created for %d volumes. Threshold=%.2f%%. Skipped: %d (garbage<threshold)",
totalVolumes,
workerConfig.GarbageThreshold*100,
skippedDueToGarbage,
)
} else {
summaryMessage = fmt.Sprintf(
"VACUUM: Created %d task(s) from %d volumes. Threshold=%.2f%%",
len(results),
totalVolumes,
workerConfig.GarbageThreshold*100,
)
}
if err := sender.SendActivity(pluginworker.BuildDetectorActivity(summaryStage, summaryMessage, map[string]*plugin_pb.ConfigValue{
"total_volumes": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(totalVolumes)},
},
"selected_tasks": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(results))},
},
"garbage_threshold_percent": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: workerConfig.GarbageThreshold * 100},
},
"skipped_garbage": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedDueToGarbage)},
},
})); err != nil {
return err
}
limit := 3
if len(metrics) < limit {
limit = len(metrics)
}
for i := 0; i < limit; i++ {
metric := metrics[i]
if metric == nil {
continue
}
message := fmt.Sprintf(
"VACUUM: Volume %d: garbage=%.2f%% (need ≥%.2f%%)",
metric.VolumeID,
metric.GarbageRatio*100,
workerConfig.GarbageThreshold*100,
)
if err := sender.SendActivity(pluginworker.BuildDetectorActivity("decision_volume", message, map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(metric.VolumeID)},
},
"garbage_percent": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: metric.GarbageRatio * 100},
},
"required_garbage_percent": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: workerConfig.GarbageThreshold * 100},
},
})); err != nil {
return err
}
}
return nil
}
func (h *VacuumHandler) 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 != "vacuum" {
return fmt.Errorf("job type %q is not handled by vacuum worker", request.Job.JobType)
}
params, err := decodeVacuumTaskParams(request.Job)
if err != nil {
return err
}
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
return fmt.Errorf("vacuum task source node is required")
}
workerConfig := deriveVacuumConfig(request.GetWorkerConfigValues())
if vacuumParams := params.GetVacuumParams(); vacuumParams != nil {
if vacuumParams.GarbageThreshold <= 0 {
vacuumParams.GarbageThreshold = workerConfig.GarbageThreshold
}
} else {
params.TaskParams = &worker_pb.TaskParams_VacuumParams{
VacuumParams: &worker_pb.VacuumTaskParams{
GarbageThreshold: workerConfig.GarbageThreshold,
BatchSize: defaultVacuumTaskBatchSize,
VerifyChecksum: true,
},
}
}
servers := make([]string, 0, len(params.Sources))
for _, src := range params.Sources {
if src != nil && src.Node != "" {
servers = append(servers, src.Node)
}
}
task := NewVacuumTask(
request.Job.JobId,
servers,
params.VolumeId,
params.Collection,
h.grpcDialOption,
)
execCtx, execCancel := context.WithCancel(ctx)
defer execCancel()
task.SetProgressCallback(func(progress float64, stage string) {
message := fmt.Sprintf("vacuum progress %.0f%%", progress)
if strings.TrimSpace(stage) != "" {
message = stage
}
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
State: plugin_pb.JobState_JOB_STATE_RUNNING,
ProgressPercent: progress,
Stage: stage,
Message: message,
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity(stage, message),
},
}); err != nil {
execCancel()
}
})
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
ProgressPercent: 0,
Stage: "assigned",
Message: "vacuum job accepted",
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("assigned", "vacuum job accepted"),
},
}); err != nil {
return err
}
if err := task.Execute(execCtx, params); err != nil {
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
State: plugin_pb.JobState_JOB_STATE_FAILED,
ProgressPercent: 100,
Stage: "failed",
Message: err.Error(),
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("failed", err.Error()),
},
})
return err
}
resultSummary := fmt.Sprintf("vacuum completed for volume %d", params.VolumeId)
return sender.SendCompleted(&plugin_pb.JobCompleted{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
Success: true,
Result: &plugin_pb.JobResult{
Summary: resultSummary,
OutputValues: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(params.VolumeId)},
},
"server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: params.Sources[0].Node},
},
},
},
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("completed", resultSummary),
},
})
}
func (h *VacuumHandler) collectVolumeMetrics(
ctx context.Context,
masterAddresses []string,
collectionFilter string,
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
metrics, activeTopology, _, err := pluginworker.CollectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
return metrics, activeTopology, err
}
func deriveVacuumConfig(values map[string]*plugin_pb.ConfigValue) *Config {
config := NewDefaultConfig()
config.GarbageThreshold = pluginworker.ReadDoubleConfig(values, "garbage_threshold", config.GarbageThreshold)
// Match the shell/master vacuum, which applies no volume-age gate (default 0),
// but honor an explicit override instead of silently dropping it.
config.MinVolumeAgeSeconds = pluginworker.ReadIntConfig(values, "min_volume_age_seconds", 0)
return config
}
func buildVacuumProposal(result *workertypes.TaskDetectionResult) (*plugin_pb.JobProposal, error) {
if result == nil {
return nil, fmt.Errorf("task detection result is nil")
}
if result.TypedParams == nil {
return nil, fmt.Errorf("missing typed params for volume %d", result.VolumeID)
}
paramsPayload, err := proto.Marshal(result.TypedParams)
if err != nil {
return nil, fmt.Errorf("marshal task params: %w", err)
}
proposalID := strings.TrimSpace(result.TaskID)
if proposalID == "" {
proposalID = fmt.Sprintf("vacuum-%d-%d", result.VolumeID, time.Now().UnixNano())
}
dedupeKey := fmt.Sprintf("vacuum:%d", result.VolumeID)
if result.Collection != "" {
dedupeKey = dedupeKey + ":" + result.Collection
}
summary := fmt.Sprintf("Vacuum volume %d", result.VolumeID)
if strings.TrimSpace(result.Server) != "" {
summary = summary + " on " + result.Server
}
// Estimate runtime: 5 min/GB (compact + commit + overhead)
volumeSizeGB := int64(result.TypedParams.VolumeSize/1024/1024/1024) + 1
estimatedRuntimeSeconds := volumeSizeGB * 5 * 60
return &plugin_pb.JobProposal{
ProposalId: proposalID,
DedupeKey: dedupeKey,
JobType: "vacuum",
Priority: pluginworker.MapTaskPriority(result.Priority),
Summary: summary,
Detail: strings.TrimSpace(result.Reason),
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {
Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: paramsPayload},
},
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(result.VolumeID)},
},
"server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Server},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Collection},
},
"estimated_runtime_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: estimatedRuntimeSeconds},
},
},
Labels: map[string]string{
"task_type": "vacuum",
"volume_id": fmt.Sprintf("%d", result.VolumeID),
"collection": result.Collection,
"source_node": result.Server,
},
}, nil
}
func decodeVacuumTaskParams(job *plugin_pb.JobSpec) (*worker_pb.TaskParams, error) {
if job == nil {
return nil, fmt.Errorf("job spec is nil")
}
if payload := pluginworker.ReadBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
params := &worker_pb.TaskParams{}
if err := proto.Unmarshal(payload, params); err != nil {
return nil, fmt.Errorf("unmarshal task_params_pb: %w", err)
}
if params.TaskId == "" {
params.TaskId = job.JobId
}
return params, nil
}
volumeID := pluginworker.ReadUint32Config(job.Parameters, "volume_id", 0)
server := pluginworker.ReadStringConfig(job.Parameters, "server", "")
collection := pluginworker.ReadStringConfig(job.Parameters, "collection", "")
if volumeID == 0 {
return nil, fmt.Errorf("missing volume_id in job parameters")
}
if strings.TrimSpace(server) == "" {
return nil, fmt.Errorf("missing server in job parameters")
}
return &worker_pb.TaskParams{
TaskId: job.JobId,
VolumeId: volumeID,
Collection: collection,
Sources: []*worker_pb.TaskSource{
{
Node: server,
VolumeId: volumeID,
},
},
TaskParams: &worker_pb.TaskParams_VacuumParams{
VacuumParams: &worker_pb.VacuumTaskParams{
GarbageThreshold: 0.3,
BatchSize: defaultVacuumTaskBatchSize,
VerifyChecksum: true,
},
},
}, nil
}

View File

@@ -0,0 +1,373 @@
package vacuum
import (
"context"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestDecodeVacuumTaskParamsFromPayload(t *testing.T) {
expected := &worker_pb.TaskParams{
TaskId: "task-1",
VolumeId: 42,
Collection: "photos",
Sources: []*worker_pb.TaskSource{
{
Node: "10.0.0.1:8080",
VolumeId: 42,
},
},
TaskParams: &worker_pb.TaskParams_VacuumParams{
VacuumParams: &worker_pb.VacuumTaskParams{
GarbageThreshold: 0.33,
BatchSize: 500,
VerifyChecksum: true,
},
},
}
payload, err := proto.Marshal(expected)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
job := &plugin_pb.JobSpec{
JobId: "job-from-admin",
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
},
}
actual, err := decodeVacuumTaskParams(job)
if err != nil {
t.Fatalf("decodeVacuumTaskParams() err = %v", err)
}
if !proto.Equal(expected, actual) {
t.Fatalf("decoded params mismatch\nexpected: %+v\nactual: %+v", expected, actual)
}
}
func TestDecodeVacuumTaskParamsFallback(t *testing.T) {
job := &plugin_pb.JobSpec{
JobId: "job-2",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7}},
"server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "127.0.0.1:8080"}},
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "videos"}},
},
}
params, err := decodeVacuumTaskParams(job)
if err != nil {
t.Fatalf("decodeVacuumTaskParams() err = %v", err)
}
if params.TaskId != "job-2" || params.VolumeId != 7 || params.Collection != "videos" {
t.Fatalf("unexpected basic params: %+v", params)
}
if len(params.Sources) != 1 || params.Sources[0].Node != "127.0.0.1:8080" {
t.Fatalf("unexpected sources: %+v", params.Sources)
}
if params.GetVacuumParams() == nil {
t.Fatalf("expected fallback vacuum params")
}
}
func TestDeriveVacuumConfigAllowsZeroValues(t *testing.T) {
values := map[string]*plugin_pb.ConfigValue{
"garbage_threshold": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0},
},
}
cfg := deriveVacuumConfig(values)
if cfg.GarbageThreshold != 0 {
t.Fatalf("expected garbage_threshold 0, got %v", cfg.GarbageThreshold)
}
}
func TestMasterAddressCandidates(t *testing.T) {
candidates := pluginworker.MasterAddressCandidates("localhost:9333")
if len(candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d: %v", len(candidates), candidates)
}
seen := map[string]bool{}
for _, candidate := range candidates {
seen[candidate] = true
}
if !seen["localhost:9333"] {
t.Fatalf("expected original address in candidates: %v", candidates)
}
if !seen["localhost:19333"] {
t.Fatalf("expected grpc address in candidates: %v", candidates)
}
}
func TestShouldSkipDetectionByInterval(t *testing.T) {
if pluginworker.ShouldSkipDetectionByInterval(nil, 10) {
t.Fatalf("expected false when timestamp is nil")
}
if pluginworker.ShouldSkipDetectionByInterval(timestamppb.Now(), 0) {
t.Fatalf("expected false when min interval is zero")
}
recent := timestamppb.New(time.Now().Add(-5 * time.Second))
if !pluginworker.ShouldSkipDetectionByInterval(recent, 10) {
t.Fatalf("expected true for recent successful run")
}
old := timestamppb.New(time.Now().Add(-30 * time.Second))
if pluginworker.ShouldSkipDetectionByInterval(old, 10) {
t.Fatalf("expected false for old successful run")
}
}
func TestVacuumHandlerRejectsUnsupportedJobType(t *testing.T) {
handler := NewVacuumHandler(nil, 0)
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: "balance",
}, noopDetectionSender{})
if err == nil {
t.Fatalf("expected detect job type mismatch error")
}
err = handler.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
Job: &plugin_pb.JobSpec{JobId: "job-1", JobType: "balance"},
}, noopExecutionSender{})
if err == nil {
t.Fatalf("expected execute job type mismatch error")
}
}
func TestBuildExecutorActivity(t *testing.T) {
activity := pluginworker.BuildExecutorActivity("running", "vacuum in progress")
if activity == nil {
t.Fatalf("expected non-nil activity")
}
if activity.Source != plugin_pb.ActivitySource_ACTIVITY_SOURCE_EXECUTOR {
t.Fatalf("unexpected source: %v", activity.Source)
}
if activity.Stage != "running" {
t.Fatalf("unexpected stage: %q", activity.Stage)
}
if activity.Message != "vacuum in progress" {
t.Fatalf("unexpected message: %q", activity.Message)
}
if activity.CreatedAt == nil {
t.Fatalf("expected created_at timestamp")
}
}
func TestEmitVacuumDetectionDecisionTraceNoTasks(t *testing.T) {
sender := &recordingDetectionSender{}
config := NewDefaultConfig()
config.GarbageThreshold = 0.3
config.MinVolumeAgeSeconds = 0
metrics := []*workertypes.VolumeHealthMetrics{
{
VolumeID: 17,
GarbageRatio: 0,
Age: 218*time.Hour + 23*time.Minute,
},
{
VolumeID: 16,
GarbageRatio: 0,
Age: 218*time.Hour + 22*time.Minute,
},
{
VolumeID: 6,
GarbageRatio: 0,
Age: 90*time.Hour + 42*time.Minute,
},
}
if err := emitVacuumDetectionDecisionTrace(sender, metrics, config, nil); err != nil {
t.Fatalf("emitVacuumDetectionDecisionTrace error: %v", err)
}
if len(sender.events) < 4 {
t.Fatalf("expected at least 4 detection events, got %d", len(sender.events))
}
if sender.events[0].Source != plugin_pb.ActivitySource_ACTIVITY_SOURCE_DETECTOR {
t.Fatalf("expected detector source, got %v", sender.events[0].Source)
}
if !strings.Contains(sender.events[0].Message, "VACUUM: No tasks created for 3 volumes") {
t.Fatalf("unexpected summary message: %q", sender.events[0].Message)
}
if !strings.Contains(sender.events[1].Message, "VACUUM: Volume 17: garbage=0.00%") {
t.Fatalf("unexpected first detail message: %q", sender.events[1].Message)
}
}
func TestVacuumDescriptorHasNewFields(t *testing.T) {
handler := NewVacuumHandler(nil, 2)
desc := handler.Descriptor()
if desc == nil {
t.Fatal("descriptor is nil")
}
adminForm := desc.AdminConfigForm
if adminForm == nil {
t.Fatal("admin config form is nil")
}
if len(adminForm.Sections) == 0 {
t.Fatal("admin config form has no sections")
}
scopeSection := adminForm.Sections[0]
fieldNames := make(map[string]*plugin_pb.ConfigField)
for _, f := range scopeSection.Fields {
fieldNames[f.Name] = f
}
requiredFields := []string{
"collection_filter",
"volume_state",
"data_center_filter",
"rack_filter",
"node_filter",
}
for _, name := range requiredFields {
if _, ok := fieldNames[name]; !ok {
t.Errorf("missing field %q in admin config scope section", name)
}
}
// Verify volume_state is an enum with correct options.
vsField := fieldNames["volume_state"]
if vsField.FieldType != plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM {
t.Errorf("volume_state field type = %v, want ENUM", vsField.FieldType)
}
if len(vsField.Options) != 3 {
t.Errorf("volume_state options count = %d, want 3", len(vsField.Options))
}
// Verify default values exist for new fields.
defaultKeys := []string{"volume_state", "data_center_filter", "rack_filter", "node_filter"}
for _, key := range defaultKeys {
if _, ok := adminForm.DefaultValues[key]; !ok {
t.Errorf("missing default value for %q", key)
}
}
// Verify volume_state default is ALL.
vsDefault := adminForm.DefaultValues["volume_state"]
if sv, ok := vsDefault.Kind.(*plugin_pb.ConfigValue_StringValue); !ok || sv.StringValue != "ALL" {
t.Errorf("volume_state default = %v, want ALL", vsDefault)
}
// Verify collection_filter description mentions ALL_COLLECTIONS.
cfField := fieldNames["collection_filter"]
if cfField.Placeholder != "ALL_COLLECTIONS" {
t.Errorf("collection_filter placeholder = %q, want ALL_COLLECTIONS", cfField.Placeholder)
}
}
func TestVacuumFiltersVolumeState(t *testing.T) {
metrics := []*workertypes.VolumeHealthMetrics{
{VolumeID: 1, FullnessRatio: 0.5}, // active
{VolumeID: 2, FullnessRatio: 1.5}, // full
{VolumeID: 3, FullnessRatio: 0.9}, // active
{VolumeID: 4, FullnessRatio: 1.01}, // full (at threshold)
}
tests := []struct {
name string
state pluginworker.VolumeState
wantIDs []uint32
}{
{"ALL returns all", pluginworker.VolumeStateAll, []uint32{1, 2, 3, 4}},
{"ACTIVE returns writable", pluginworker.VolumeStateActive, []uint32{1, 3}},
{"FULL returns read-only", pluginworker.VolumeStateFull, []uint32{2, 4}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filtered := pluginworker.FilterMetricsByVolumeState(metrics, tt.state)
if len(filtered) != len(tt.wantIDs) {
t.Fatalf("got %d metrics, want %d", len(filtered), len(tt.wantIDs))
}
for i, m := range filtered {
if m.VolumeID != tt.wantIDs[i] {
t.Errorf("filtered[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.wantIDs[i])
}
}
})
}
}
func TestVacuumFiltersLocation(t *testing.T) {
metrics := []*workertypes.VolumeHealthMetrics{
{VolumeID: 1, DataCenter: "dc1", Rack: "r1", Server: "s1"},
{VolumeID: 2, DataCenter: "dc1", Rack: "r2", Server: "s2"},
{VolumeID: 3, DataCenter: "dc2", Rack: "r1", Server: "s3"},
{VolumeID: 4, DataCenter: "dc2", Rack: "r3", Server: "s4"},
}
tests := []struct {
name string
dc string
rack string
node string
wantIDs []uint32
}{
{"dc filter", "dc1", "", "", []uint32{1, 2}},
{"rack filter", "", "r1", "", []uint32{1, 3}},
{"node filter", "", "", "s2,s4", []uint32{2, 4}},
{"dc + rack", "dc1", "r1", "", []uint32{1}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filtered := pluginworker.FilterMetricsByLocation(metrics, tt.dc, tt.rack, tt.node)
if len(filtered) != len(tt.wantIDs) {
t.Fatalf("got %d metrics, want %d", len(filtered), len(tt.wantIDs))
}
for i, m := range filtered {
if m.VolumeID != tt.wantIDs[i] {
t.Errorf("filtered[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.wantIDs[i])
}
}
})
}
}
type noopDetectionSender struct{}
func (noopDetectionSender) SendProposals(*plugin_pb.DetectionProposals) error { return nil }
func (noopDetectionSender) SendComplete(*plugin_pb.DetectionComplete) error { return nil }
func (noopDetectionSender) SendActivity(*plugin_pb.ActivityEvent) error { return nil }
type noopExecutionSender struct{}
func (noopExecutionSender) SendProgress(*plugin_pb.JobProgressUpdate) error { return nil }
func (noopExecutionSender) SendCompleted(*plugin_pb.JobCompleted) error { return nil }
type recordingDetectionSender struct {
proposals *plugin_pb.DetectionProposals
complete *plugin_pb.DetectionComplete
events []*plugin_pb.ActivityEvent
}
func (r *recordingDetectionSender) SendProposals(proposals *plugin_pb.DetectionProposals) error {
r.proposals = proposals
return nil
}
func (r *recordingDetectionSender) SendComplete(complete *plugin_pb.DetectionComplete) error {
r.complete = complete
return nil
}
func (r *recordingDetectionSender) SendActivity(event *plugin_pb.ActivityEvent) error {
if event != nil {
r.events = append(r.events, event)
}
return nil
}

View File

@@ -0,0 +1,98 @@
package vacuum
import (
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Global variable to hold the task definition for configuration updates
var globalTaskDef *base.TaskDefinition
// Auto-register this task when the package is imported
func init() {
RegisterVacuumTask()
// Register config updater
tasks.AutoRegisterConfigUpdater(types.TaskTypeVacuum, UpdateConfigFromPersistence)
}
// RegisterVacuumTask registers the vacuum task with the new architecture
func RegisterVacuumTask() {
// Create configuration instance
config := NewDefaultConfig()
// Create shared gRPC dial option using TLS configuration
dialOpt := security.LoadClientTLS(util.GetViper(), "grpc.worker")
// Create complete task definition
taskDef := &base.TaskDefinition{
Type: types.TaskTypeVacuum,
Name: "vacuum",
DisplayName: "Volume Vacuum",
Description: "Reclaims disk space by removing deleted files from volumes",
Icon: "fas fa-broom text-primary",
Capabilities: []string{"vacuum", "storage"},
Config: config,
ConfigSpec: GetConfigSpec(),
CreateTask: func(params *worker_pb.TaskParams) (types.Task, error) {
if params == nil {
return nil, fmt.Errorf("task parameters are required")
}
if len(params.Sources) == 0 {
return nil, fmt.Errorf("at least one source is required for vacuum task")
}
servers := make([]string, 0, len(params.Sources))
for _, src := range params.Sources {
if src != nil && src.Node != "" {
servers = append(servers, src.Node)
}
}
return NewVacuumTask(
fmt.Sprintf("vacuum-%d", params.VolumeId),
servers,
params.VolumeId,
params.Collection,
dialOpt,
), nil
},
DetectionFunc: Detection,
ScanInterval: 2 * time.Hour,
SchedulingFunc: Scheduling,
MaxConcurrent: 2,
RepeatInterval: 7 * 24 * time.Hour,
}
// Store task definition globally for configuration updates
globalTaskDef = taskDef
// Register everything with a single function call!
base.RegisterTask(taskDef)
}
// UpdateConfigFromPersistence updates the vacuum configuration from persistence
func UpdateConfigFromPersistence(configPersistence interface{}) error {
if globalTaskDef == nil {
return fmt.Errorf("vacuum task not registered")
}
// Load configuration from persistence
newConfig := LoadConfigFromPersistence(configPersistence)
if newConfig == nil {
return fmt.Errorf("failed to load configuration from persistence")
}
// Update the task definition's config
globalTaskDef.Config = newConfig
glog.V(1).Infof("Updated vacuum task configuration from persistence")
return nil
}

View File

@@ -0,0 +1,37 @@
package vacuum
import (
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Scheduling implements the scheduling logic for vacuum tasks
func Scheduling(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData, config base.TaskConfig) bool {
vacuumConfig := config.(*Config)
// Count running vacuum tasks
runningVacuumCount := 0
for _, runningTask := range runningTasks {
if runningTask.Type == types.TaskTypeVacuum {
runningVacuumCount++
}
}
// Check concurrency limit
if runningVacuumCount >= vacuumConfig.MaxConcurrent {
return false
}
// Check for available workers with vacuum capability
for _, worker := range availableWorkers {
if worker.CurrentLoad < worker.MaxConcurrent {
for _, capability := range worker.Capabilities {
if capability == types.TaskTypeVacuum {
return true
}
}
}
}
return false
}

View File

@@ -0,0 +1,444 @@
package vacuum
import (
"context"
"fmt"
"io"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
"github.com/seaweedfs/seaweedfs/weed/worker/types/base"
"google.golang.org/grpc"
)
// VacuumTask implements the Task interface.
//
// One task covers all replicas of a volume so behavior matches the master
// built-in vacuum (see topology.Topology.vacuumOneVolumeId): Check across
// every replica → filter to those whose garbage ratio meets the threshold
// → Compact/Commit/Cleanup that subset. Treating one replica per task (the
// prior behavior) drops the other N-1 replicas because the dispatcher
// gates duplicate tasks per volume via ActiveTopology.HasAnyTask.
type VacuumTask struct {
*base.BaseTask
servers []string
volumeID uint32
collection string
garbageThreshold float64
progress float64
grpcDialOption grpc.DialOption
volumeSize uint64
vacuumTargets []string // populated by checkVacuumEligibility — subset of servers that pass the per-replica garbage re-check and proceed to Compact/Commit/Cleanup
}
// NewVacuumTask creates a new unified vacuum task instance covering every
// replica server reported by the dispatcher.
func NewVacuumTask(id string, servers []string, volumeID uint32, collection string, grpcDialOption grpc.DialOption) *VacuumTask {
deduped := dedupePreserveOrder(servers)
return &VacuumTask{
BaseTask: base.NewBaseTask(id, types.TaskTypeVacuum),
servers: deduped,
volumeID: volumeID,
collection: collection,
garbageThreshold: 0.3, // Default 30% threshold
grpcDialOption: grpcDialOption,
}
}
// Execute implements the UnifiedTask interface
func (t *VacuumTask) Execute(ctx context.Context, params *worker_pb.TaskParams) error {
if params == nil {
return fmt.Errorf("task parameters are required")
}
vacuumParams := params.GetVacuumParams()
if vacuumParams == nil {
return fmt.Errorf("vacuum parameters are required")
}
t.garbageThreshold = vacuumParams.GarbageThreshold
t.volumeSize = params.VolumeSize
t.GetLogger().WithFields(map[string]interface{}{
"volume_id": t.volumeID,
"servers": t.servers,
"collection": t.collection,
"garbage_threshold": t.garbageThreshold,
}).Info("Starting vacuum task")
if len(t.servers) == 0 {
return fmt.Errorf("no source servers configured for vacuum task")
}
// Step 1: Check vacuum eligibility for each replica. Mirrors
// topology.batchVacuumVolumeCheck — only replicas whose garbage is at
// or above the threshold proceed to Compact/Commit/Cleanup.
t.ReportProgress(10.0)
t.GetLogger().Info("Checking volume status")
targets, currentGarbageRatios, err := t.checkVacuumEligibility(ctx)
if err != nil {
return fmt.Errorf("failed to check vacuum eligibility: %w", err)
}
if len(targets) == 0 {
t.GetLogger().WithFields(map[string]interface{}{
"garbage_ratios": currentGarbageRatios,
"required_threshold": t.garbageThreshold,
}).Info("No replica meets vacuum criteria, skipping")
t.ReportProgress(100.0)
return nil
}
t.vacuumTargets = targets
// Step 2: Perform vacuum (compact + commit + cleanup) across every
// target replica.
t.ReportProgress(50.0)
t.GetLogger().WithFields(map[string]interface{}{
"vacuum_targets": targets,
"garbage_ratios": currentGarbageRatios,
"threshold": t.garbageThreshold,
}).Info("Performing vacuum operation")
if err := t.performVacuum(ctx); err != nil {
return fmt.Errorf("failed to perform vacuum: %w", err)
}
// Step 3: Verify vacuum results on each target replica.
t.ReportProgress(90.0)
t.GetLogger().Info("Verifying vacuum results")
if err := t.verifyVacuumResults(ctx); err != nil {
glog.Warningf("Vacuum verification failed: %v", err)
// Don't fail the task - vacuum operation itself succeeded
}
t.ReportProgress(100.0)
glog.Infof("Vacuum task completed successfully: volume %d on %v (garbage ratios %v)",
t.volumeID, targets, currentGarbageRatios)
return nil
}
// Validate implements the UnifiedTask interface
func (t *VacuumTask) Validate(params *worker_pb.TaskParams) error {
if params == nil {
return fmt.Errorf("task parameters are required")
}
vacuumParams := params.GetVacuumParams()
if vacuumParams == nil {
return fmt.Errorf("vacuum parameters are required")
}
if params.VolumeId != t.volumeID {
return fmt.Errorf("volume ID mismatch: expected %d, got %d", t.volumeID, params.VolumeId)
}
// Every server the task was created with must appear in the params'
// Sources list. The dispatcher fills Sources from the detection-time
// replica set, so a mismatch means the worker received stale routing.
sourceSet := make(map[string]struct{}, len(params.Sources))
for _, source := range params.Sources {
if source == nil {
continue
}
sourceSet[source.Node] = struct{}{}
}
for _, server := range t.servers {
if _, ok := sourceSet[server]; !ok {
return fmt.Errorf("task server %s not present in params.Sources", server)
}
}
if vacuumParams.GarbageThreshold < 0 || vacuumParams.GarbageThreshold > 1.0 {
return fmt.Errorf("invalid garbage threshold: %f (must be between 0.0 and 1.0)", vacuumParams.GarbageThreshold)
}
return nil
}
// EstimateTime implements the UnifiedTask interface
func (t *VacuumTask) EstimateTime(params *worker_pb.TaskParams) time.Duration {
// Basic estimate based on simulated steps
return 14 * time.Second // Sum of all step durations
}
// GetProgress returns current progress
func (t *VacuumTask) GetProgress() float64 {
return t.progress
}
// vacuumTimeout returns a dynamic timeout scaled by volume size, matching the
// topology vacuum approach. base is the per-GB multiplier (e.g. 1 minute for
// check, 3 minutes for compact).
func (t *VacuumTask) vacuumTimeout(base time.Duration) time.Duration {
if t.volumeSize == 0 {
glog.V(1).Infof("volume %d has no size metric, using minimum timeout", t.volumeID)
}
sizeGB := int64(t.volumeSize/1024/1024/1024) + 1
return base * time.Duration(sizeGB)
}
// Helper methods for real vacuum operations
// checkVacuumEligibility queries every replica's current garbage ratio.
// Mirrors topology.batchVacuumVolumeCheck's all-or-nothing contract: if
// any replica's check fails (unreachable, RPC error, etc.) the entire
// task aborts. Vacuuming only the replicas that responded while
// silently skipping unreachable ones would compact the responders'
// garbage but leave the unreachable replica still carrying it,
// producing divergence the moment that replica comes back.
//
// Returns the subset of servers whose garbage is at or above the
// configured threshold, alongside a per-server ratio map for logging.
func (t *VacuumTask) checkVacuumEligibility(ctx context.Context) ([]string, map[string]float64, error) {
ratios := make(map[string]float64, len(t.servers))
for _, server := range t.servers {
ratio, err := t.checkOneVacuumEligibility(ctx, server)
if err != nil {
return nil, ratios, fmt.Errorf("vacuum check on %s for volume %d: %w (aborting; refusing to vacuum subset of replicas)", server, t.volumeID, err)
}
ratios[server] = ratio
glog.V(1).Infof("Volume %d on %s garbage ratio: %.2f%%, threshold: %.2f%%",
t.volumeID, server, ratio*100, t.garbageThreshold*100)
}
eligible := make([]string, 0, len(ratios))
for _, server := range t.servers {
if ratios[server] >= t.garbageThreshold {
eligible = append(eligible, server)
}
}
return eligible, ratios, nil
}
func (t *VacuumTask) checkOneVacuumEligibility(ctx context.Context, server string) (float64, error) {
var garbageRatio float64
err := operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
func(client volume_server_pb.VolumeServerClient) error {
checkCtx, cancel := context.WithTimeout(ctx, t.vacuumTimeout(time.Minute))
defer cancel()
resp, err := client.VacuumVolumeCheck(checkCtx, &volume_server_pb.VacuumVolumeCheckRequest{
VolumeId: t.volumeID,
})
if err != nil {
return fmt.Errorf("failed to check volume vacuum status: %v", err)
}
garbageRatio = resp.GarbageRatio
return nil
})
return garbageRatio, err
}
// performVacuum runs the three-phase vacuum protocol that mirrors
// master built-in vacuum (topology.vacuumOneVolumeId):
//
// Phase 1 (Compact): build the new .cpd/.cpx files on every target.
// If any replica fails, roll back by Cleanup'ing the .cp* temp files
// on every target and abort — no replica has yet swapped its active
// files, so no replica is committed.
//
// Phase 2 (Commit): swap each target's active files with its .cp*
// files. Best-effort, matching batchVacuumVolumeCommit: per-replica
// errors are logged and surfaced together, but once any replica has
// swapped there is no clean rollback for the others, so we do not
// retry or undo. An operator must reconcile a partial commit
// failure.
//
// Phase 3 (Mark Writable): re-notify the master per replica so the
// volume re-enters the writable set, the worker analog of
// batchVacuumVolumeCommit's per-replica SetVolumeAvailable. Skipped
// when a replica came back read-only. Best-effort; never fails the
// task.
//
// Interleaving Compact→Commit→Cleanup per replica (the prior behavior)
// could leave a committed first replica beside an uncompacted second
// replica when Compact on the second failed — replica divergence with
// no automatic recovery.
func (t *VacuumTask) performVacuum(ctx context.Context) error {
// Phase 1: Compact all targets.
for _, server := range t.vacuumTargets {
if err := t.compactOne(ctx, server); err != nil {
t.cleanupAll(ctx)
return fmt.Errorf("vacuum compact on %s: %w", server, err)
}
}
// Phase 2: Commit all targets, tracking whether any replica is still
// read-only after the swap.
var commitErrors []error
anyReadOnly := false
for _, server := range t.vacuumTargets {
resp, err := t.commitOne(ctx, server)
if err != nil {
glog.Errorf("vacuum commit on %s for volume %d: %v", server, t.volumeID, err)
commitErrors = append(commitErrors, fmt.Errorf("%s: %w", server, err))
continue
}
if resp.GetIsReadOnly() {
anyReadOnly = true
}
}
if len(commitErrors) > 0 {
return fmt.Errorf("vacuum commit failed on %d/%d replicas: %v",
len(commitErrors), len(t.vacuumTargets), commitErrors)
}
// Phase 3: re-notify the master so the volume re-enters the writable
// set. The worker's only lever is VolumeMarkWritable, which clears the
// read-only flag and runs notifyMasterVolumeReadonly(false). Gate on
// the commit's IsReadOnly exactly as SetVolumeAvailable does: a replica
// still read-only (operator-set, EIO-quarantined, or disk-space-low)
// must stay out, and recovers on its own via the next ReadOnly=false
// heartbeat — force-clearing the flag here would override that. The
// worker is not told the master's size limit, so the isFullCapacity
// guard is left to the next capacity heartbeat. Best-effort: the vacuum
// itself already succeeded.
if anyReadOnly {
glog.V(0).Infof("post-vacuum: volume %d still read-only on a replica, leaving it out of writables", t.volumeID)
return nil
}
for _, server := range t.vacuumTargets {
if err := t.markWritableOne(ctx, server); err != nil {
glog.Warningf("post-vacuum mark writable on %s for volume %d: %v", server, t.volumeID, err)
continue
}
glog.V(0).Infof("post-vacuum marked volume %d writable on %s", t.volumeID, server)
}
return nil
}
func (t *VacuumTask) compactOne(ctx context.Context, server string) error {
return operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
func(client volume_server_pb.VolumeServerClient) error {
t.GetLogger().Info("Compacting volume on %s", server)
compactCtx, cancel := context.WithTimeout(ctx, t.vacuumTimeout(3*time.Minute))
defer cancel()
stream, err := client.VacuumVolumeCompact(compactCtx, &volume_server_pb.VacuumVolumeCompactRequest{
VolumeId: t.volumeID,
})
if err != nil {
return fmt.Errorf("vacuum compact start: %v", err)
}
for {
resp, recvErr := stream.Recv()
if recvErr != nil {
if recvErr == io.EOF {
break
}
return fmt.Errorf("vacuum compact stream: %v", recvErr)
}
glog.V(2).Infof("Volume %d on %s compact progress: %d bytes", t.volumeID, server, resp.ProcessedBytes)
}
return nil
})
}
func (t *VacuumTask) commitOne(ctx context.Context, server string) (*volume_server_pb.VacuumVolumeCommitResponse, error) {
var resp *volume_server_pb.VacuumVolumeCommitResponse
err := operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
func(client volume_server_pb.VolumeServerClient) error {
t.GetLogger().Info("Committing vacuum on %s", server)
commitCtx, cancel := context.WithTimeout(ctx, t.vacuumTimeout(time.Minute))
defer cancel()
var err error
resp, err = client.VacuumVolumeCommit(commitCtx, &volume_server_pb.VacuumVolumeCommitRequest{
VolumeId: t.volumeID,
})
if err != nil {
return fmt.Errorf("vacuum commit: %v", err)
}
return nil
})
return resp, err
}
func (t *VacuumTask) cleanupOne(ctx context.Context, server string) error {
return operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
func(client volume_server_pb.VolumeServerClient) error {
cleanupCtx, cancel := context.WithTimeout(ctx, t.vacuumTimeout(time.Minute))
defer cancel()
_, err := client.VacuumVolumeCleanup(cleanupCtx, &volume_server_pb.VacuumVolumeCleanupRequest{
VolumeId: t.volumeID,
})
return err
})
}
func (t *VacuumTask) markWritableOne(ctx context.Context, server string) error {
return operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
func(client volume_server_pb.VolumeServerClient) error {
// VolumeMarkWritable is a metadata RPC (reopen idx + flags +
// notifyMasterVolumeReadonly heartbeat) — millisecond-scale and
// independent of volume size. A flat 1m cap prevents an
// unresponsive replica from blocking Phase 3 for hours on a
// TB-scale volume where vacuumTimeout() would balloon.
markCtx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
_, err := client.VolumeMarkWritable(markCtx, &volume_server_pb.VolumeMarkWritableRequest{
VolumeId: t.volumeID,
})
return err
})
}
// cleanupAll removes the .cpd/.cpx/.cpldb temp files on every target.
// Used to roll back when Compact fails on one replica after others
// have already created their temp files. Per-target failures are
// logged but never bubble up — the rollback is best-effort.
func (t *VacuumTask) cleanupAll(ctx context.Context) {
for _, server := range t.vacuumTargets {
if err := t.cleanupOne(ctx, server); err != nil {
glog.Warningf("rollback cleanup on %s for volume %d: %v", server, t.volumeID, err)
}
}
}
// verifyVacuumResults checks each target replica's post-vacuum garbage
// ratio. Failures are logged at WARN — the task does not fail because the
// vacuum itself already succeeded.
func (t *VacuumTask) verifyVacuumResults(ctx context.Context) error {
for _, server := range t.vacuumTargets {
err := operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
func(client volume_server_pb.VolumeServerClient) error {
verifyCtx, cancel := context.WithTimeout(ctx, t.vacuumTimeout(time.Minute))
defer cancel()
resp, err := client.VacuumVolumeCheck(verifyCtx, &volume_server_pb.VacuumVolumeCheckRequest{
VolumeId: t.volumeID,
})
if err != nil {
return fmt.Errorf("failed to verify vacuum results: %v", err)
}
glog.V(1).Infof("Volume %d on %s post-vacuum garbage ratio: %.2f%%",
t.volumeID, server, resp.GarbageRatio*100)
return nil
})
if err != nil {
glog.Warningf("post-vacuum verify on %s: %v", server, err)
}
}
return nil
}
// dedupePreserveOrder returns servers with duplicates removed, keeping the
// first occurrence's position. Detection sometimes hands the same node
// address in multiple Sources (e.g. EC variants); we coalesce them so each
// physical replica is vacuumed exactly once.
func dedupePreserveOrder(servers []string) []string {
seen := make(map[string]struct{}, len(servers))
out := make([]string, 0, len(servers))
for _, s := range servers {
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}