feat(balance): add volume state filter (ALL/ACTIVE/FULL) (#8619)
* feat(balance): add volume state filter (ALL/ACTIVE/FULL) Add a volume_state admin config field to the plugin worker volume balance handler, matching the shell's -volumeBy flag. This allows filtering volumes by state before balance detection: - ALL (default): consider all volumes - ACTIVE: only writable volumes below the size limit (FullnessRatio < 1.01) - FULL: only read-only volumes above the size limit (FullnessRatio >= 1.01) The 1.01 threshold mirrors the shell's thresholdVolumeSize constant. * address PR review: use enum/select widget, switch-based filter, nil safety - Change volume_state field from string/text to enum/select with dropdown options (ALL, ACTIVE, FULL) - Refactor filterMetricsByVolumeState to use switch with predicate function for clearer extensibility - Add nil-check guard to prevent panic on nil metric elements - Add TestFilterMetricsByVolumeState_NilElement regression test
This commit is contained in:
@@ -90,6 +90,18 @@ func (h *VolumeBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
|||||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
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: "ALL", Label: "All Volumes"},
|
||||||
|
{Value: "ACTIVE", Label: "Active (writable)"},
|
||||||
|
{Value: "FULL", Label: "Full (read-only)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -97,6 +109,9 @@ func (h *VolumeBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
|||||||
"collection_filter": {
|
"collection_filter": {
|
||||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||||
},
|
},
|
||||||
|
"volume_state": {
|
||||||
|
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "ALL"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||||
@@ -266,6 +281,9 @@ func (h *VolumeBalanceHandler) Detect(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
volumeState := strings.ToUpper(strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "volume_state", "ALL")))
|
||||||
|
metrics = filterMetricsByVolumeState(metrics, volumeState)
|
||||||
|
|
||||||
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
|
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
|
||||||
maxResults := int(request.MaxResults)
|
maxResults := int(request.MaxResults)
|
||||||
results, hasMore, err := balancetask.Detection(metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
|
results, hasMore, err := balancetask.Detection(metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
|
||||||
@@ -544,6 +562,39 @@ func emitVolumeBalanceDetectionDecisionTrace(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterMetricsByVolumeState filters volume metrics by state.
|
||||||
|
// "ACTIVE" keeps volumes with FullnessRatio < 1.01 (writable, below size limit).
|
||||||
|
// "FULL" keeps volumes with FullnessRatio >= 1.01 (read-only, above size limit).
|
||||||
|
// "ALL" or any other value returns all metrics unfiltered.
|
||||||
|
func filterMetricsByVolumeState(metrics []*workertypes.VolumeHealthMetrics, volumeState string) []*workertypes.VolumeHealthMetrics {
|
||||||
|
const fullnessThreshold = 1.01
|
||||||
|
|
||||||
|
var predicate func(m *workertypes.VolumeHealthMetrics) bool
|
||||||
|
switch volumeState {
|
||||||
|
case "ACTIVE":
|
||||||
|
predicate = func(m *workertypes.VolumeHealthMetrics) bool {
|
||||||
|
return m.FullnessRatio < fullnessThreshold
|
||||||
|
}
|
||||||
|
case "FULL":
|
||||||
|
predicate = func(m *workertypes.VolumeHealthMetrics) bool {
|
||||||
|
return m.FullnessRatio >= fullnessThreshold
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]*workertypes.VolumeHealthMetrics, 0, len(metrics))
|
||||||
|
for _, m := range metrics {
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if predicate(m) {
|
||||||
|
filtered = append(filtered, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
func countBalanceDiskTypes(metrics []*workertypes.VolumeHealthMetrics) int {
|
func countBalanceDiskTypes(metrics []*workertypes.VolumeHealthMetrics) int {
|
||||||
diskTypes := make(map[string]struct{})
|
diskTypes := make(map[string]struct{})
|
||||||
for _, metric := range metrics {
|
for _, metric := range metrics {
|
||||||
|
|||||||
@@ -629,6 +629,118 @@ func TestExecuteSingleMovePathUnchanged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterMetricsByVolumeState(t *testing.T) {
|
||||||
|
metrics := []*workertypes.VolumeHealthMetrics{
|
||||||
|
{VolumeID: 1, FullnessRatio: 0.5}, // active
|
||||||
|
{VolumeID: 2, FullnessRatio: 1.0}, // active (below 1.01)
|
||||||
|
{VolumeID: 3, FullnessRatio: 1.009}, // active (below 1.01)
|
||||||
|
{VolumeID: 4, FullnessRatio: 1.01}, // full (exactly at threshold)
|
||||||
|
{VolumeID: 5, FullnessRatio: 1.5}, // full
|
||||||
|
{VolumeID: 6, FullnessRatio: 2.0}, // full
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
state string
|
||||||
|
expectedIDs []uint32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ALL returns everything",
|
||||||
|
state: "ALL",
|
||||||
|
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string returns everything",
|
||||||
|
state: "",
|
||||||
|
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ACTIVE keeps FullnessRatio below 1.01",
|
||||||
|
state: "ACTIVE",
|
||||||
|
expectedIDs: []uint32{1, 2, 3},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FULL keeps FullnessRatio at or above 1.01",
|
||||||
|
state: "FULL",
|
||||||
|
expectedIDs: []uint32{4, 5, 6},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown value returns everything",
|
||||||
|
state: "INVALID",
|
||||||
|
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := filterMetricsByVolumeState(metrics, tt.state)
|
||||||
|
if len(result) != len(tt.expectedIDs) {
|
||||||
|
t.Fatalf("expected %d metrics, got %d", len(tt.expectedIDs), len(result))
|
||||||
|
}
|
||||||
|
for i, m := range result {
|
||||||
|
if m.VolumeID != tt.expectedIDs[i] {
|
||||||
|
t.Errorf("result[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.expectedIDs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterMetricsByVolumeState_NilElement(t *testing.T) {
|
||||||
|
metrics := []*workertypes.VolumeHealthMetrics{
|
||||||
|
nil,
|
||||||
|
{VolumeID: 1, FullnessRatio: 0.5},
|
||||||
|
nil,
|
||||||
|
{VolumeID: 2, FullnessRatio: 1.5},
|
||||||
|
}
|
||||||
|
result := filterMetricsByVolumeState(metrics, "ACTIVE")
|
||||||
|
if len(result) != 1 || result[0].VolumeID != 1 {
|
||||||
|
t.Fatalf("expected [vol 1] for ACTIVE with nil elements, got %d results", len(result))
|
||||||
|
}
|
||||||
|
result = filterMetricsByVolumeState(metrics, "FULL")
|
||||||
|
if len(result) != 1 || result[0].VolumeID != 2 {
|
||||||
|
t.Fatalf("expected [vol 2] for FULL with nil elements, got %d results", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterMetricsByVolumeState_EmptyInput(t *testing.T) {
|
||||||
|
result := filterMetricsByVolumeState(nil, "ACTIVE")
|
||||||
|
if len(result) != 0 {
|
||||||
|
t.Fatalf("expected 0 metrics for nil input, got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
result = filterMetricsByVolumeState([]*workertypes.VolumeHealthMetrics{}, "FULL")
|
||||||
|
if len(result) != 0 {
|
||||||
|
t.Fatalf("expected 0 metrics for empty input, got %d", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVolumeBalanceDescriptorHasVolumeStateField(t *testing.T) {
|
||||||
|
descriptor := NewVolumeBalanceHandler(nil).Descriptor()
|
||||||
|
if descriptor == nil || descriptor.AdminConfigForm == nil {
|
||||||
|
t.Fatalf("expected admin config form in descriptor")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, section := range descriptor.AdminConfigForm.Sections {
|
||||||
|
for _, field := range section.Fields {
|
||||||
|
if field.Name == "volume_state" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected volume_state field in admin config form")
|
||||||
|
}
|
||||||
|
defaultVal, ok := descriptor.AdminConfigForm.DefaultValues["volume_state"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected volume_state default value")
|
||||||
|
}
|
||||||
|
if defaultVal.GetStringValue() != "ALL" {
|
||||||
|
t.Fatalf("expected volume_state default 'ALL', got %q", defaultVal.GetStringValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func workerConfigFormHasField(form *plugin_pb.ConfigForm, fieldName string) bool {
|
func workerConfigFormHasField(form *plugin_pb.ConfigForm, fieldName string) bool {
|
||||||
if form == nil {
|
if form == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user