mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-07-29 19:43:19 +00:00
* admin: let waiting shell clients win the admin lock The admin lock manager re-acquired the cluster shell lock immediately after releasing it, while a waiting weed shell client only polls the master once a second, so an operator could lose the race indefinitely. Leave the lock free for slightly more than one poll interval before re-acquiring, and once overlapping reference-counted holds have kept the lock continuously held for over a minute, make new admin-side acquires wait for a full release before piggybacking. * admin/plugin: take the shell lock per detection and per job, not per batch The default-lane scheduler acquired the cluster shell lock once and held it across the whole pass: every due job type's detection plus all of its dispatched jobs, each drained to completion. A manual weed shell operation sharing that lock could stall behind the batch for up to the extended execution window. Hold the lock only while it protects something: around each detection scan and around each dispatched job. Manual shell operations now wait for at most one in-flight job. The admin UI detect+execute path stops wrapping dispatch in an outer hold, since a nested acquire would deadlock against the lock manager's fairness window; detection and per-job dispatch take the lock themselves. * admin/plugin: stop extending the dispatch window to the largest job estimate When any proposal's estimated runtime exceeded the remaining JobTypeMaxRuntime, the whole dispatch context was replaced with a fresh one capped at eight hours, so a single balance backlog could hold the default lane (and with it erasure_coding and vacuum detection) for that long. Keep the dispatch window at JobTypeMaxRuntime and instead detach each started attempt onto its own estimated-runtime deadline. Large jobs still get their full time once started; jobs not yet started when the window closes are canceled and re-proposed by a later detection, so sibling job types get a turn every window. * worker/balance: re-check each planned move against the master before executing A balance plan is computed at detection time, but the admin lock is released between detection and execution, so a manual shell operation can rearrange the volume in the gap. The task's own guards catch a vanished source, but a target that gained a replica in the meantime would be silently overwritten by VolumeCopy and the source delete would then reduce the volume to a single copy. Before executing each move, ask the master for the volume's current locations (uncached) and skip the move if the volume has left the source or the target already holds a replica. Skipped moves fail with a stale-move error and the next detection replans them. Without master addresses in the cluster context the check is skipped, preserving the old behavior with older admins. * admin: block re-acquire while the final lock release is in flight Release dropped the manager mutex before calling ReleaseLock, so a concurrent Acquire could see hold count zero and call RequestLock while the locker still considered itself locked. That request no-ops, leaving a hold with no live master lease. Track the in-flight release and make Acquire wait for it. Also normalize a nil release function from lock manager implementations, and make the yield and fairness windows per-instance fields so tests stop mutating globals.
1479 lines
48 KiB
Go
1479 lines
48 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/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}},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
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},
|
|
},
|
|
"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},
|
|
},
|
|
"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 {
|
|
sourceFound := false
|
|
for _, location := range locations {
|
|
switch strings.TrimSpace(location) {
|
|
case targetNode:
|
|
return fmt.Errorf("stale move: volume %d already has a replica on target %s", volumeID, targetNode)
|
|
case sourceNode:
|
|
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
|
|
if outerParams != nil {
|
|
if outerParams.TimeoutSeconds > 0 {
|
|
timeoutSeconds = outerParams.TimeoutSeconds
|
|
}
|
|
forceMove = outerParams.ForceMove
|
|
}
|
|
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,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
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
|
|
taskParams := &worker_pb.TaskParams{
|
|
TaskParams: &worker_pb.TaskParams_BalanceParams{
|
|
BalanceParams: &worker_pb.BalanceTaskParams{
|
|
TimeoutSeconds: defaultBalanceTimeoutSeconds,
|
|
MaxConcurrentMoves: int32(maxConcurrentMoves),
|
|
Moves: moves,
|
|
},
|
|
},
|
|
}
|
|
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)
|
|
|
|
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,
|
|
},
|
|
},
|
|
}, 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
|
|
}
|