Files
seaweedfs/weed/worker/tasks/balance/plugin_handler.go
T
Chris LuandGitHub 4f50c5b0d4 feat: throughput limits for replicate, EC shard, and worker-driven moves (#10749)
* feat: throughput limits for replicate, EC shard, and worker-driven moves

VolumeCopy was the only rate-limitable transfer; EC shard copies,
replica creation, and worker-driven moves all ran at whatever the
receiving server's maintenance rate allowed, with no per-operation
control.

- proto: VolumeEcShardsCopyRequest and the balance / ec_balance task
  params and configs gain io_byte_per_second; 0 keeps today's behavior
  (the volume server's own maintenance rate governs).
- volume server: VolumeEcShardsCopy throttles with one WriteThrottler
  per request, shared across the shard, .ecx, .ecj, .vif, and .ecsum
  copies so the limit caps the transfer as a whole - the same shape as
  VolumeCopy.
- volume_move: ReplicateVolume accepts the limit; EcMoveOptions carries
  it through MoveEcShards/CopyAndMountEcShards into the copy request,
  with fake-client tests asserting propagation.
- shell: ec.balance gains -ioBytePerSecond; volume.tier.move's
  replication top-up honors the command's existing -ioBytePerSecond
  instead of running unthrottled.
- worker: balance and ec_balance configs gain io_byte_per_second
  (surfaced in the admin config schema), carried through detection and
  plugin job parameters into task params and handed to the shared
  mover; batch balance jobs inherit the limit from their detection
  results.

The limit is per copy stream, so maxParallelization multiplies the
aggregate ceiling.

* worker plugins: expose io_byte_per_second in the plugin config and derive it

The plugin-driven detection path derives its task Config from the
plugin configuration values, and both balance and ec_balance left
IoBytePerSecond at zero there - a configured limit silently reverted
to the server maintenance rate. Both derive functions now read the
field (clamped at zero), and the plugin descriptors expose it with
defaults so the configuration form carries it.
2026-08-13 13:22:58 -07:00

1516 lines
50 KiB
Go

package balance
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"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 (
defaultBalanceTimeoutSeconds = int32(10 * 60)
maxProposalStringLength = 200
)
func init() {
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
JobType: "volume_balance",
Category: pluginworker.CategoryDefault,
Aliases: []string{"balance", "volume.balance", "volume-balance"},
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
return NewVolumeBalanceHandler(opts.GrpcDialOption), nil
},
})
}
type volumeBalanceWorkerConfig struct {
TaskConfig *Config
MaxConcurrentMoves int
BatchSize int
}
// VolumeBalanceHandler is the plugin job handler for volume balancing.
type VolumeBalanceHandler struct {
grpcDialOption grpc.DialOption
}
func NewVolumeBalanceHandler(grpcDialOption grpc.DialOption) *VolumeBalanceHandler {
return &VolumeBalanceHandler{grpcDialOption: grpcDialOption}
}
func (h *VolumeBalanceHandler) Capability() *plugin_pb.JobTypeCapability {
return &plugin_pb.JobTypeCapability{
JobType: "volume_balance",
CanDetect: true,
CanExecute: true,
MaxDetectionConcurrency: 1,
MaxExecutionConcurrency: 1,
DisplayName: "Volume Balance",
Description: "Moves volumes between servers to reduce skew in volume distribution",
Weight: 50,
}
}
func (h *VolumeBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
return &plugin_pb.JobTypeDescriptor{
JobType: "volume_balance",
DisplayName: "Volume Balance",
Description: "Detect and execute volume moves to balance server load",
Icon: "fas fa-balance-scale",
DescriptorVersion: 1,
AdminConfigForm: &plugin_pb.ConfigForm{
FormId: "volume-balance-admin",
Title: "Volume Balance Admin Config",
Description: "Admin-side controls for volume balance detection scope.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "scope",
Title: "Scope",
Description: "Optional filters applied before balance detection.",
Fields: []*plugin_pb.ConfigField{
{
Name: "collection_filter",
Label: "Collection Filter",
Description: "Filter collections for balance detection. Use ALL_COLLECTIONS (default) to treat all volumes as one pool, EACH_COLLECTION to run detection separately per collection, or a regex pattern to match specific collections.",
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 balance 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 balance 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 balance 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: "volume-balance-worker",
Title: "Volume Balance Worker Config",
Description: "Worker-side balance thresholds.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "thresholds",
Title: "Detection Thresholds",
Description: "Controls for when balance jobs should be proposed.",
Fields: []*plugin_pb.ConfigField{
{
Name: "imbalance_threshold",
Label: "Imbalance Threshold",
Description: "Detect when skew exceeds this ratio.",
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_server_count",
Label: "Minimum Server Count",
Description: "Require at least this many servers for 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: 2}},
},
{
Name: "io_byte_per_second",
Label: "Move IO Limit (bytes/sec)",
Description: "Limit each volume move's 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}},
},
},
},
{
SectionId: "batch_execution",
Title: "Batch Execution",
Description: "Controls for running multiple volume moves per job. The worker coordinates moves via gRPC and is not on the data path.",
Fields: []*plugin_pb.ConfigField{
{
Name: "max_concurrent_moves",
Label: "Max Concurrent Moves",
Description: "Maximum number of volume moves to run concurrently within a single batch job.",
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: 50}},
},
{
Name: "batch_size",
Label: "Batch Size",
Description: "Maximum number of volume moves to group into a single job. Set to 1 to disable batching.",
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: 100}},
},
},
},
},
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: 2},
},
"io_byte_per_second": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0},
},
"max_concurrent_moves": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(defaultMaxConcurrentMoves)},
},
"batch_size": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 20},
},
},
},
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
Enabled: true,
DetectionIntervalMinutes: 30,
DetectionTimeoutSeconds: 120,
MaxJobsPerDetection: 100,
GlobalExecutionConcurrency: 16,
PerWorkerExecutionConcurrency: 4,
RetryLimit: 1,
RetryBackoffSeconds: 15,
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: 2},
},
"io_byte_per_second": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0},
},
"max_concurrent_moves": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(defaultMaxConcurrentMoves)},
},
"batch_size": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 20},
},
},
}
}
func (h *VolumeBalanceHandler) 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 != "volume_balance" {
return fmt.Errorf("job type %q is not handled by volume_balance worker", request.JobType)
}
workerConfig := deriveBalanceWorkerConfig(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, replicaMap, 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)
}
workerConfig.TaskConfig.DataCenterFilter = dataCenterFilter
workerConfig.TaskConfig.RackFilter = rackFilter
workerConfig.TaskConfig.NodeFilter = nodeFilter
clusterInfo := &workertypes.ClusterInfo{
ActiveTopology: activeTopology,
VolumeReplicaMap: replicaMap,
}
maxResults := int(request.MaxResults)
var results []*workertypes.TaskDetectionResult
var hasMore bool
if pluginworker.CollectionFilterMode(collectionFilter) == pluginworker.CollectionFilterEach {
// Group metrics by collection in a single pass (O(N) instead of O(C*N))
metricsByCollection := make(map[string][]*workertypes.VolumeHealthMetrics)
for _, m := range metrics {
if m == nil {
continue
}
metricsByCollection[m.Collection] = append(metricsByCollection[m.Collection], m)
}
collections := make([]string, 0, len(metricsByCollection))
for c := range metricsByCollection {
collections = append(collections, c)
}
sort.Strings(collections)
budget := maxResults
unlimitedBudget := budget <= 0
for _, collection := range collections {
if !unlimitedBudget && budget <= 0 {
hasMore = true
break
}
perCollectionLimit := budget
if unlimitedBudget {
perCollectionLimit = 0 // Detection treats <= 0 as unbounded
}
perResults, perHasMore, perErr := Detection(metricsByCollection[collection], clusterInfo, workerConfig.TaskConfig, perCollectionLimit)
if perErr != nil {
return perErr
}
results = append(results, perResults...)
if !unlimitedBudget {
budget -= len(perResults)
}
if perHasMore {
hasMore = true
}
}
} else {
var err error
results, hasMore, err = Detection(metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
if err != nil {
return err
}
}
if traceErr := emitVolumeBalanceDetectionDecisionTrace(sender, metrics, activeTopology, workerConfig.TaskConfig, results); traceErr != nil {
glog.Warningf("Plugin worker failed to emit volume_balance detection trace: %v", traceErr)
}
var proposals []*plugin_pb.JobProposal
if workerConfig.BatchSize > 1 && len(results) > 1 {
proposals = buildBatchVolumeBalanceProposals(results, workerConfig.BatchSize, workerConfig.MaxConcurrentMoves)
} else {
proposals = make([]*plugin_pb.JobProposal, 0, len(results))
for _, result := range results {
proposal, proposalErr := buildVolumeBalanceProposal(result)
if proposalErr != nil {
glog.Warningf("Plugin worker skip invalid volume_balance proposal: %v", proposalErr)
continue
}
proposals = append(proposals, proposal)
}
}
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
JobType: "volume_balance",
Proposals: proposals,
HasMore: hasMore,
}); err != nil {
return err
}
return sender.SendComplete(&plugin_pb.DetectionComplete{
JobType: "volume_balance",
Success: true,
TotalProposals: int32(len(proposals)),
})
}
func emitVolumeBalanceDetectionDecisionTrace(
sender pluginworker.DetectionSender,
metrics []*workertypes.VolumeHealthMetrics,
activeTopology *topology.ActiveTopology,
taskConfig *Config,
results []*workertypes.TaskDetectionResult,
) error {
if sender == nil || taskConfig == nil {
return nil
}
totalVolumes := len(metrics)
summaryMessage := ""
if len(results) == 0 {
summaryMessage = fmt.Sprintf(
"BALANCE: No tasks created for %d volumes across %d disk type(s). Threshold=%.1f%%, MinServers=%d",
totalVolumes,
countBalanceDiskTypes(metrics),
taskConfig.ImbalanceThreshold*100,
taskConfig.MinServerCount,
)
} else {
summaryMessage = fmt.Sprintf(
"BALANCE: Created %d task(s) for %d volumes across %d disk type(s). Threshold=%.1f%%, MinServers=%d",
len(results),
totalVolumes,
countBalanceDiskTypes(metrics),
taskConfig.ImbalanceThreshold*100,
taskConfig.MinServerCount,
)
}
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))},
},
"imbalance_threshold_percent": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: taskConfig.ImbalanceThreshold * 100},
},
"min_server_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.MinServerCount)},
},
})); err != nil {
return err
}
volumesByDiskType := make(map[string][]*workertypes.VolumeHealthMetrics)
for _, metric := range metrics {
if metric == nil {
continue
}
diskType := strings.TrimSpace(metric.DiskType)
if diskType == "" {
diskType = "unknown"
}
volumesByDiskType[diskType] = append(volumesByDiskType[diskType], metric)
}
diskTypes := make([]string, 0, len(volumesByDiskType))
for diskType := range volumesByDiskType {
diskTypes = append(diskTypes, diskType)
}
sort.Strings(diskTypes)
const minVolumeCount = 2
detailCount := 0
for _, diskType := range diskTypes {
diskMetrics := volumesByDiskType[diskType]
volumeCount := len(diskMetrics)
if volumeCount < minVolumeCount {
message := fmt.Sprintf(
"BALANCE [%s]: No tasks created - cluster too small (%d volumes, need ≥%d)",
diskType,
volumeCount,
minVolumeCount,
)
if err := sender.SendActivity(pluginworker.BuildDetectorActivity("decision_disk_type", message, map[string]*plugin_pb.ConfigValue{
"disk_type": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: diskType},
},
"volume_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(volumeCount)},
},
"required_min_volume_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: minVolumeCount},
},
})); err != nil {
return err
}
detailCount++
if detailCount >= 3 {
break
}
continue
}
// Seed server counts from topology so zero-volume servers are included,
// matching the same logic used in Detection.
serverVolumeCounts := make(map[string]int)
if activeTopology != nil {
topologyInfo := activeTopology.GetTopologyInfo()
if topologyInfo != nil {
for _, dc := range topologyInfo.DataCenterInfos {
for _, rack := range dc.RackInfos {
for _, node := range rack.DataNodeInfos {
for diskTypeName := range node.DiskInfos {
if diskTypeName == diskType {
serverVolumeCounts[node.Id] = 0
}
}
}
}
}
}
}
for _, metric := range diskMetrics {
serverVolumeCounts[metric.Server]++
}
if len(serverVolumeCounts) < taskConfig.MinServerCount {
message := fmt.Sprintf(
"BALANCE [%s]: No tasks created - too few servers (%d servers, need ≥%d)",
diskType,
len(serverVolumeCounts),
taskConfig.MinServerCount,
)
if err := sender.SendActivity(pluginworker.BuildDetectorActivity("decision_disk_type", message, map[string]*plugin_pb.ConfigValue{
"disk_type": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: diskType},
},
"server_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(serverVolumeCounts))},
},
"required_min_server_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.MinServerCount)},
},
})); err != nil {
return err
}
detailCount++
if detailCount >= 3 {
break
}
continue
}
totalDiskTypeVolumes := len(diskMetrics)
avgVolumesPerServer := float64(totalDiskTypeVolumes) / float64(len(serverVolumeCounts))
maxVolumes := 0
minVolumes := totalDiskTypeVolumes
maxServer := ""
minServer := ""
for server, count := range serverVolumeCounts {
if count > maxVolumes {
maxVolumes = count
maxServer = server
}
if count < minVolumes {
minVolumes = count
minServer = server
}
}
imbalanceRatio := 0.0
if avgVolumesPerServer > 0 {
imbalanceRatio = float64(maxVolumes-minVolumes) / avgVolumesPerServer
}
stage := "decision_disk_type"
message := ""
if imbalanceRatio <= taskConfig.ImbalanceThreshold {
message = fmt.Sprintf(
"BALANCE [%s]: No tasks created - cluster well balanced. Imbalance=%.1f%% (threshold=%.1f%%). Max=%d volumes on %s, Min=%d on %s, Avg=%.1f",
diskType,
imbalanceRatio*100,
taskConfig.ImbalanceThreshold*100,
maxVolumes,
maxServer,
minVolumes,
minServer,
avgVolumesPerServer,
)
} else {
stage = "decision_candidate"
message = fmt.Sprintf(
"BALANCE [%s]: Candidate detected. Imbalance=%.1f%% (threshold=%.1f%%). Max=%d volumes on %s, Min=%d on %s, Avg=%.1f",
diskType,
imbalanceRatio*100,
taskConfig.ImbalanceThreshold*100,
maxVolumes,
maxServer,
minVolumes,
minServer,
avgVolumesPerServer,
)
}
if err := sender.SendActivity(pluginworker.BuildDetectorActivity(stage, message, map[string]*plugin_pb.ConfigValue{
"disk_type": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: diskType},
},
"volume_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(totalDiskTypeVolumes)},
},
"server_count": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(serverVolumeCounts))},
},
"imbalance_percent": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: imbalanceRatio * 100},
},
"threshold_percent": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: taskConfig.ImbalanceThreshold * 100},
},
"max_volumes": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(maxVolumes)},
},
"min_volumes": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(minVolumes)},
},
"avg_volumes_per_server": {
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: avgVolumesPerServer},
},
})); err != nil {
return err
}
detailCount++
if detailCount >= 3 {
break
}
}
return nil
}
func countBalanceDiskTypes(metrics []*workertypes.VolumeHealthMetrics) int {
diskTypes := make(map[string]struct{})
for _, metric := range metrics {
if metric == nil {
continue
}
diskType := strings.TrimSpace(metric.DiskType)
if diskType == "" {
diskType = "unknown"
}
diskTypes[diskType] = struct{}{}
}
return len(diskTypes)
}
const (
defaultMaxConcurrentMoves = 5
maxConcurrentMovesLimit = 50
maxBatchMoves = 100
)
func (h *VolumeBalanceHandler) 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 != "volume_balance" {
return fmt.Errorf("job type %q is not handled by volume_balance worker", request.Job.JobType)
}
params, err := decodeVolumeBalanceTaskParams(request.Job)
if err != nil {
return err
}
applyBalanceExecutionDefaults(params)
// Batch path: if BalanceTaskParams has moves, execute them concurrently
if bp := params.GetBalanceParams(); bp != nil && len(bp.Moves) > 0 {
return h.executeBatchMoves(ctx, request, params, sender)
}
// Single-move path (backward compatible)
return h.executeSingleMove(ctx, request, params, sender)
}
func (h *VolumeBalanceHandler) executeSingleMove(
ctx context.Context,
request *plugin_pb.ExecuteJobRequest,
params *worker_pb.TaskParams,
sender pluginworker.ExecutionSender,
) error {
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
return fmt.Errorf("volume balance source node is required")
}
if len(params.Targets) == 0 || strings.TrimSpace(params.Targets[0].Node) == "" {
return fmt.Errorf("volume balance target node is required")
}
task := NewBalanceTask(
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("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 {
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: "volume balance job accepted",
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("assigned", "volume balance job accepted"),
},
}); err != nil {
return err
}
execErr := h.checkMoveStillValid(execCtx, request.GetClusterContext().GetMasterGrpcAddresses(), params.VolumeId, params.Sources[0].Node, params.Targets[0].Node)
if execErr == nil {
execErr = task.Execute(execCtx, params)
}
if execErr != 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: execErr.Error(),
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("failed", execErr.Error()),
},
})
return execErr
}
sourceNode := params.Sources[0].Node
targetNode := params.Targets[0].Node
resultSummary := fmt.Sprintf("volume %d 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),
},
})
}
// checkMoveStillValid re-checks a planned move against the master's live
// view right before executing it. The admin lock is released between
// detection and execution, so the plan can go stale: the volume may have
// left the source, or the target may have gained a replica that the copy
// would overwrite and the source delete would then reduce to a single copy.
// Without master addresses (older admin) the check is skipped and the move
// relies on the task's own execution-time guards.
func (h *VolumeBalanceHandler) checkMoveStillValid(ctx context.Context, masterAddresses []string, volumeID uint32, sourceNode, targetNode string) error {
if len(masterAddresses) == 0 {
glog.Warningf("volume balance: no master addresses in cluster context, skipping pre-move check for volume %d", volumeID)
return nil
}
locations, err := pluginworker.LookupVolumeLocations(ctx, masterAddresses, h.grpcDialOption, volumeID)
if err != nil {
return fmt.Errorf("pre-move check for volume %d: %w", volumeID, err)
}
return checkMovePreconditions(locations, volumeID, sourceNode, targetNode)
}
func checkMovePreconditions(locations []string, volumeID uint32, sourceNode, targetNode string) error {
// Proposal nodes carry the grpc suffix (host:port.grpcPort) while the
// master reports plain host:port urls, so compare in http form.
sourceAddress := pb.ServerAddress(strings.TrimSpace(sourceNode))
targetAddress := pb.ServerAddress(strings.TrimSpace(targetNode))
sourceFound := false
for _, location := range locations {
locationAddress := pb.ServerAddress(strings.TrimSpace(location))
switch {
case locationAddress.Equals(targetAddress):
return fmt.Errorf("stale move: volume %d already has a replica on target %s", volumeID, targetNode)
case locationAddress.Equals(sourceAddress):
sourceFound = true
}
}
if !sourceFound {
return fmt.Errorf("stale move: volume %d is no longer on source %s", volumeID, sourceNode)
}
return nil
}
// executeBatchMoves runs multiple volume moves concurrently within a single job.
func (h *VolumeBalanceHandler) executeBatchMoves(
ctx context.Context,
request *plugin_pb.ExecuteJobRequest,
params *worker_pb.TaskParams,
sender pluginworker.ExecutionSender,
) error {
bp := params.GetBalanceParams()
if len(bp.Moves) == 0 {
return fmt.Errorf("batch balance job has no moves")
}
if len(bp.Moves) > maxBatchMoves {
return fmt.Errorf("batch balance job has %d moves, exceeding limit of %d", len(bp.Moves), maxBatchMoves)
}
// Filter out nil or incomplete moves before scheduling.
validMoves := make([]*worker_pb.BalanceMoveSpec, 0, len(bp.Moves))
for _, m := range bp.Moves {
if m == nil {
continue
}
if strings.TrimSpace(m.SourceNode) == "" || strings.TrimSpace(m.TargetNode) == "" || m.VolumeId == 0 {
glog.Warningf("batch balance: skipping invalid move (vol:%d src:%q tgt:%q)", m.VolumeId, m.SourceNode, m.TargetNode)
continue
}
validMoves = append(validMoves, m)
}
if len(validMoves) == 0 {
return fmt.Errorf("batch balance job has no valid moves after validation")
}
moves := validMoves
maxConcurrent := int(bp.MaxConcurrentMoves)
if maxConcurrent <= 0 {
maxConcurrent = defaultMaxConcurrentMoves
}
// Clamp to the worker-side upper bound so a stale or malicious job
// cannot request unbounded fan-out of concurrent volume moves.
if maxConcurrent > maxConcurrentMovesLimit {
maxConcurrent = maxConcurrentMovesLimit
}
totalMoves := len(moves)
glog.Infof("batch volume balance: %d moves, max concurrent %d", totalMoves, maxConcurrent)
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: fmt.Sprintf("batch volume balance accepted: %d moves", totalMoves),
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("assigned", fmt.Sprintf("batch volume balance: %d moves, concurrency %d", totalMoves, maxConcurrent)),
},
}); err != nil {
return err
}
// Derive a cancellable context so we can abort remaining moves if the
// progress stream breaks (client disconnect, context cancelled).
batchCtx, batchCancel := context.WithCancel(ctx)
defer batchCancel()
// Per-move progress tracking. The mutex serializes both the progress
// bookkeeping and the sender.SendProgress call, since the underlying
// gRPC stream is not safe for concurrent writes.
var progressMu sync.Mutex
moveProgress := make([]float64, totalMoves)
var sendErr error // first progress send error
reportAggregate := func(moveIndex int, progress float64, stage string) {
progressMu.Lock()
defer progressMu.Unlock()
if sendErr != nil {
return // stream already broken, skip further sends
}
moveProgress[moveIndex] = progress
total := 0.0
for _, p := range moveProgress {
total += p
}
aggregate := total / float64(totalMoves)
move := moves[moveIndex]
message := fmt.Sprintf("[Move %d/%d vol:%d] %s", moveIndex+1, totalMoves, move.VolumeId, stage)
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
State: plugin_pb.JobState_JOB_STATE_RUNNING,
ProgressPercent: aggregate,
Stage: fmt.Sprintf("move %d/%d", moveIndex+1, totalMoves),
Message: message,
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity(fmt.Sprintf("move-%d", moveIndex+1), message),
},
}); err != nil {
sendErr = err
batchCancel() // cancel in-flight and pending moves
}
}
type moveResult struct {
index int
volumeID uint32
source string
target string
err error
}
sem := make(chan struct{}, maxConcurrent)
results := make(chan moveResult, totalMoves)
masterAddresses := request.GetClusterContext().GetMasterGrpcAddresses()
for i, move := range moves {
sem <- struct{}{} // acquire slot
go func(idx int, m *worker_pb.BalanceMoveSpec) {
defer func() { <-sem }() // release slot
task := NewBalanceTask(
fmt.Sprintf("%s-move-%d", request.Job.JobId, idx),
m.SourceNode,
m.VolumeId,
m.Collection,
h.grpcDialOption,
)
task.SetProgressCallback(func(progress float64, stage string) {
reportAggregate(idx, progress, stage)
})
moveParams := buildMoveTaskParams(m, bp)
err := h.checkMoveStillValid(batchCtx, masterAddresses, m.VolumeId, m.SourceNode, m.TargetNode)
if err == nil {
err = task.Execute(batchCtx, moveParams)
}
results <- moveResult{
index: idx,
volumeID: m.VolumeId,
source: m.SourceNode,
target: m.TargetNode,
err: err,
}
}(i, move)
}
// Collect all results
var succeeded, failed int
var errMessages []string
for range moves {
r := <-results
if r.err != nil {
failed++
errMessages = append(errMessages, fmt.Sprintf("volume %d (%s→%s): %v", r.volumeID, r.source, r.target, r.err))
glog.Warningf("batch balance move %d failed: volume %d %s→%s: %v", r.index, r.volumeID, r.source, r.target, r.err)
} else {
succeeded++
}
}
summary := fmt.Sprintf("%d/%d volumes moved successfully", succeeded, totalMoves)
if failed > 0 {
summary += fmt.Sprintf("; %d failed", failed)
}
// Mark the job as successful if at least one move succeeded. This avoids
// the standard retry path re-running already-completed moves. The failed
// move details are available in ErrorMessage and result metadata so a
// retry mechanism can operate only on the failed items.
success := succeeded > 0 || failed == 0
var errMsg string
if failed > 0 {
errMsg = strings.Join(errMessages, "; ")
}
return sender.SendCompleted(&plugin_pb.JobCompleted{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
Success: success,
ErrorMessage: errMsg,
Result: &plugin_pb.JobResult{
Summary: summary,
OutputValues: map[string]*plugin_pb.ConfigValue{
"total_moves": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(totalMoves)},
},
"succeeded": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(succeeded)},
},
"failed": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(failed)},
},
},
},
Activities: []*plugin_pb.ActivityEvent{
pluginworker.BuildExecutorActivity("completed", summary),
},
})
}
// buildMoveTaskParams constructs a TaskParams for a single move within a batch.
func buildMoveTaskParams(move *worker_pb.BalanceMoveSpec, outerParams *worker_pb.BalanceTaskParams) *worker_pb.TaskParams {
timeoutSeconds := defaultBalanceTimeoutSeconds
forceMove := false
var ioBytePerSecond int64
if outerParams != nil {
if outerParams.TimeoutSeconds > 0 {
timeoutSeconds = outerParams.TimeoutSeconds
}
forceMove = outerParams.ForceMove
ioBytePerSecond = outerParams.IoBytePerSecond
}
return &worker_pb.TaskParams{
VolumeId: move.VolumeId,
Collection: move.Collection,
VolumeSize: move.VolumeSize,
Sources: []*worker_pb.TaskSource{
{Node: move.SourceNode, VolumeId: move.VolumeId},
},
Targets: []*worker_pb.TaskTarget{
{Node: move.TargetNode, VolumeId: move.VolumeId},
},
TaskParams: &worker_pb.TaskParams_BalanceParams{
BalanceParams: &worker_pb.BalanceTaskParams{
ForceMove: forceMove,
TimeoutSeconds: timeoutSeconds,
IoBytePerSecond: ioBytePerSecond,
},
},
}
}
func (h *VolumeBalanceHandler) collectVolumeMetrics(
ctx context.Context,
masterAddresses []string,
collectionFilter string,
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, map[uint32][]workertypes.ReplicaLocation, error) {
return pluginworker.CollectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
}
func deriveBalanceWorkerConfig(values map[string]*plugin_pb.ConfigValue) *volumeBalanceWorkerConfig {
taskConfig := NewDefaultConfig()
imbalanceThreshold := pluginworker.ReadDoubleConfig(values, "imbalance_threshold", taskConfig.ImbalanceThreshold)
if imbalanceThreshold < 0 {
imbalanceThreshold = 0
}
if imbalanceThreshold > 1 {
imbalanceThreshold = 1
}
taskConfig.ImbalanceThreshold = imbalanceThreshold
minServerCount := pluginworker.ReadIntConfig(values, "min_server_count", taskConfig.MinServerCount)
if minServerCount < 2 {
minServerCount = 2
}
taskConfig.MinServerCount = minServerCount
ioBytePerSecond := pluginworker.ReadInt64Config(values, "io_byte_per_second", taskConfig.IoBytePerSecond)
if ioBytePerSecond < 0 {
ioBytePerSecond = 0
}
taskConfig.IoBytePerSecond = ioBytePerSecond
maxConcurrentMoves := pluginworker.ReadIntConfig(values, "max_concurrent_moves", defaultMaxConcurrentMoves)
if maxConcurrentMoves < 1 {
maxConcurrentMoves = 1
}
if maxConcurrentMoves > 50 {
maxConcurrentMoves = 50
}
batchSize := pluginworker.ReadIntConfig(values, "batch_size", 20)
if batchSize < 1 {
batchSize = 1
}
if batchSize > 100 {
batchSize = 100
}
return &volumeBalanceWorkerConfig{
TaskConfig: taskConfig,
MaxConcurrentMoves: maxConcurrentMoves,
BatchSize: batchSize,
}
}
func buildVolumeBalanceProposal(
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)
}
params := proto.Clone(result.TypedParams).(*worker_pb.TaskParams)
applyBalanceExecutionDefaults(params)
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("volume-balance-%d-%d", result.VolumeID, time.Now().UnixNano())
}
dedupeKey := fmt.Sprintf("volume_balance:%d", result.VolumeID)
if result.Collection != "" {
dedupeKey += ":" + result.Collection
}
sourceNode := ""
if len(params.Sources) > 0 {
sourceNode = strings.TrimSpace(params.Sources[0].Node)
}
targetNode := ""
if len(params.Targets) > 0 {
targetNode = strings.TrimSpace(params.Targets[0].Node)
}
summary := fmt.Sprintf("Balance volume %d", result.VolumeID)
if sourceNode != "" && targetNode != "" {
summary = fmt.Sprintf("Move volume %d from %s to %s", result.VolumeID, sourceNode, targetNode)
}
// Estimate runtime at 5 min/GB (matches vacuum) so the scheduler grants
// a per-attempt deadline that scales with volume size instead of the
// 2*DetectionTimeout default.
volumeSizeGB := int64(result.TypedParams.VolumeSize/1024/1024/1024) + 1
estimatedRuntimeSeconds := volumeSizeGB * 5 * 60
return &plugin_pb.JobProposal{
ProposalId: proposalID,
DedupeKey: dedupeKey,
JobType: "volume_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": "balance",
"volume_id": fmt.Sprintf("%d", result.VolumeID),
"collection": result.Collection,
"source_node": sourceNode,
"target_node": targetNode,
"source_server": sourceNode,
"target_server": targetNode,
},
}, nil
}
// buildBatchVolumeBalanceProposals groups detection results into batch proposals.
// Each batch proposal encodes multiple moves in BalanceTaskParams.Moves.
func buildBatchVolumeBalanceProposals(
results []*workertypes.TaskDetectionResult,
batchSize int,
maxConcurrentMoves int,
) []*plugin_pb.JobProposal {
if batchSize <= 0 {
batchSize = 1
}
if maxConcurrentMoves <= 0 {
maxConcurrentMoves = defaultMaxConcurrentMoves
}
var proposals []*plugin_pb.JobProposal
for batchStart := 0; batchStart < len(results); batchStart += batchSize {
batchEnd := batchStart + batchSize
if batchEnd > len(results) {
batchEnd = len(results)
}
batch := results[batchStart:batchEnd]
// If only one result in this batch, emit a single-move proposal
if len(batch) == 1 {
proposal, err := buildVolumeBalanceProposal(batch[0])
if err != nil {
glog.Warningf("Plugin worker skip invalid volume_balance proposal: %v", err)
continue
}
proposals = append(proposals, proposal)
continue
}
// Build batch proposal with BalanceMoveSpec entries
moves := make([]*worker_pb.BalanceMoveSpec, 0, len(batch))
var volumeIDs []string
var dedupeKeys []string
highestPriority := workertypes.TaskPriorityLow
for _, result := range batch {
if result == nil || result.TypedParams == nil {
continue
}
sourceNode := ""
targetNode := ""
if len(result.TypedParams.Sources) > 0 {
sourceNode = result.TypedParams.Sources[0].Node
}
if len(result.TypedParams.Targets) > 0 {
targetNode = result.TypedParams.Targets[0].Node
}
// Skip moves with missing required fields that would fail at execution time.
if result.VolumeID == 0 || sourceNode == "" || targetNode == "" {
glog.Warningf("Plugin worker skip invalid batch move: volume=%d source=%q target=%q", result.VolumeID, sourceNode, targetNode)
continue
}
moves = append(moves, &worker_pb.BalanceMoveSpec{
VolumeId: uint32(result.VolumeID),
SourceNode: sourceNode,
TargetNode: targetNode,
Collection: result.Collection,
VolumeSize: result.TypedParams.VolumeSize,
})
volumeIDs = append(volumeIDs, fmt.Sprintf("%d", result.VolumeID))
dedupeKey := fmt.Sprintf("volume_balance:%d", result.VolumeID)
if result.Collection != "" {
dedupeKey += ":" + result.Collection
}
dedupeKeys = append(dedupeKeys, dedupeKey)
if result.Priority > highestPriority {
highestPriority = result.Priority
}
}
if len(moves) == 0 {
continue
}
// After filtering, if only one valid move remains, emit a single-move
// proposal instead of a batch to preserve the simpler execution path.
if len(moves) == 1 {
// Find the matching result for the single valid move
for _, result := range batch {
if result != nil && uint32(result.VolumeID) == moves[0].VolumeId {
proposal, err := buildVolumeBalanceProposal(result)
if err != nil {
glog.Warningf("Plugin worker skip invalid volume_balance proposal: %v", err)
} else {
proposals = append(proposals, proposal)
}
break
}
}
continue
}
// Serialize batch params. The io limit rides along from the detection
// results, which carry it from the task configuration.
var ioBytePerSecond int64
if p := batch[0].TypedParams.GetBalanceParams(); p != nil {
ioBytePerSecond = p.IoBytePerSecond
}
taskParams := &worker_pb.TaskParams{
TaskParams: &worker_pb.TaskParams_BalanceParams{
BalanceParams: &worker_pb.BalanceTaskParams{
TimeoutSeconds: defaultBalanceTimeoutSeconds,
MaxConcurrentMoves: int32(maxConcurrentMoves),
Moves: moves,
IoBytePerSecond: ioBytePerSecond,
},
},
}
payload, err := proto.Marshal(taskParams)
if err != nil {
glog.Warningf("Plugin worker failed to marshal batch balance proposal: %v", err)
continue
}
// Estimate runtime so the scheduler grants a per-attempt deadline
// large enough for the whole batch. Mirrors vacuum's 5 min/GB budget.
// Use max(largest single move, total / concurrency) so a skewed batch
// with one big move isn't underestimated by the average.
var totalSeconds, maxMoveSeconds int64
for _, m := range moves {
gb := int64(m.VolumeSize/1024/1024/1024) + 1
s := gb * 5 * 60
totalSeconds += s
if s > maxMoveSeconds {
maxMoveSeconds = s
}
}
// Round up to whole scheduling rounds: with N moves and C slots,
// the busiest slot processes ceil(N/C) moves, not N/C. Using
// avg * ceil(N/C) avoids underestimating when N is not a multiple
// of C (e.g. 6 moves at concurrency 5 → 2 rounds, not 1.2).
avgSeconds := totalSeconds / int64(len(moves))
numRounds := (int64(len(moves)) + int64(maxConcurrentMoves) - 1) / int64(maxConcurrentMoves)
estimatedRuntimeSeconds := avgSeconds * numRounds
if maxMoveSeconds > estimatedRuntimeSeconds {
estimatedRuntimeSeconds = maxMoveSeconds
}
// Floor for orchestration overhead — scales per wave (one round of
// concurrent moves) rather than per move, since setup/teardown
// happens once per wave, not once per move.
if minBudget := numRounds * 60; estimatedRuntimeSeconds < minBudget {
estimatedRuntimeSeconds = minBudget
}
proposalID := fmt.Sprintf("volume-balance-batch-%d-%d", batchStart, time.Now().UnixNano())
summary := fmt.Sprintf("Batch balance %d volumes (%s)", len(moves), strings.Join(volumeIDs, ","))
if len(summary) > maxProposalStringLength {
summary = fmt.Sprintf("Batch balance %d volumes", len(moves))
}
// Use composite dedupe key for the batch. When the full key exceeds
// the length limit, fall back to a deterministic hash of the sorted
// keys so the same batch always produces the same dedupe key.
sort.Strings(dedupeKeys)
compositeDedupeKey := fmt.Sprintf("volume_balance_batch:%s", strings.Join(dedupeKeys, "+"))
if len(compositeDedupeKey) > maxProposalStringLength {
h := sha256.Sum256([]byte(strings.Join(dedupeKeys, "+")))
compositeDedupeKey = fmt.Sprintf("volume_balance_batch:%d-%s", batchStart, hex.EncodeToString(h[:12]))
}
proposals = append(proposals, &plugin_pb.JobProposal{
ProposalId: proposalID,
DedupeKey: compositeDedupeKey,
JobType: "volume_balance",
Priority: pluginworker.MapTaskPriority(highestPriority),
Summary: summary,
Detail: fmt.Sprintf("Batch of %d volume moves with concurrency %d", len(moves), maxConcurrentMoves),
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {
Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload},
},
"batch_size": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(moves))},
},
"estimated_runtime_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: estimatedRuntimeSeconds},
},
},
Labels: map[string]string{
"task_type": "balance",
"batch": "true",
"batch_size": fmt.Sprintf("%d", len(moves)),
},
})
}
return proposals
}
func decodeVolumeBalanceTaskParams(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", ""))
}
targetNode := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "target_server", ""))
if targetNode == "" {
targetNode = strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "target", ""))
}
collection := pluginworker.ReadStringConfig(job.Parameters, "collection", "")
timeoutSeconds := pluginworker.ReadInt32Config(job.Parameters, "timeout_seconds", defaultBalanceTimeoutSeconds)
if timeoutSeconds <= 0 {
timeoutSeconds = defaultBalanceTimeoutSeconds
}
forceMove := readBoolConfig(job.Parameters, "force_move", false)
ioBytePerSecond := pluginworker.ReadInt64Config(job.Parameters, "io_byte_per_second", 0)
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 targetNode == "" {
return nil, fmt.Errorf("missing target_server in job parameters")
}
return &worker_pb.TaskParams{
TaskId: job.JobId,
VolumeId: volumeID,
Collection: collection,
Sources: []*worker_pb.TaskSource{
{
Node: sourceNode,
VolumeId: volumeID,
},
},
Targets: []*worker_pb.TaskTarget{
{
Node: targetNode,
VolumeId: volumeID,
},
},
TaskParams: &worker_pb.TaskParams_BalanceParams{
BalanceParams: &worker_pb.BalanceTaskParams{
ForceMove: forceMove,
TimeoutSeconds: timeoutSeconds,
IoBytePerSecond: ioBytePerSecond,
},
},
}, nil
}
func applyBalanceExecutionDefaults(params *worker_pb.TaskParams) {
if params == nil {
return
}
balanceParams := params.GetBalanceParams()
if balanceParams == nil {
params.TaskParams = &worker_pb.TaskParams_BalanceParams{
BalanceParams: &worker_pb.BalanceTaskParams{
ForceMove: false,
TimeoutSeconds: defaultBalanceTimeoutSeconds,
},
}
return
}
if balanceParams.TimeoutSeconds <= 0 {
balanceParams.TimeoutSeconds = defaultBalanceTimeoutSeconds
}
}
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_Int64Value:
return kind.Int64Value != 0
case *plugin_pb.ConfigValue_DoubleValue:
return kind.DoubleValue != 0
case *plugin_pb.ConfigValue_StringValue:
text := strings.TrimSpace(strings.ToLower(kind.StringValue))
switch text {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return fallback
}