iceberg: add resource-group proposal controls (#8668)
* iceberg: add resource-group proposal controls * iceberg: tighten resource group config validation
This commit is contained in:
@@ -403,13 +403,13 @@ func needsMaintenance(meta table.Metadata, config Config) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildMaintenanceProposal creates a JobProposal for a table needing maintenance.
|
// buildMaintenanceProposal creates a JobProposal for a table needing maintenance.
|
||||||
func (h *Handler) buildMaintenanceProposal(t tableInfo, filerAddress string) *plugin_pb.JobProposal {
|
func (h *Handler) buildMaintenanceProposal(t tableInfo, filerAddress, resourceGroup string) *plugin_pb.JobProposal {
|
||||||
dedupeKey := fmt.Sprintf("iceberg_maintenance:%s/%s/%s", t.BucketName, t.Namespace, t.TableName)
|
dedupeKey := fmt.Sprintf("iceberg_maintenance:%s/%s/%s", t.BucketName, t.Namespace, t.TableName)
|
||||||
|
|
||||||
snapshotCount := len(t.Metadata.Snapshots())
|
snapshotCount := len(t.Metadata.Snapshots())
|
||||||
summary := fmt.Sprintf("Maintain %s/%s/%s (%d snapshots)", t.BucketName, t.Namespace, t.TableName, snapshotCount)
|
summary := fmt.Sprintf("Maintain %s/%s/%s (%d snapshots)", t.BucketName, t.Namespace, t.TableName, snapshotCount)
|
||||||
|
|
||||||
return &plugin_pb.JobProposal{
|
proposal := &plugin_pb.JobProposal{
|
||||||
ProposalId: fmt.Sprintf("iceberg-%s-%s-%s-%d", t.BucketName, t.Namespace, t.TableName, time.Now().UnixMilli()),
|
ProposalId: fmt.Sprintf("iceberg-%s-%s-%s-%d", t.BucketName, t.Namespace, t.TableName, time.Now().UnixMilli()),
|
||||||
DedupeKey: dedupeKey,
|
DedupeKey: dedupeKey,
|
||||||
JobType: jobType,
|
JobType: jobType,
|
||||||
@@ -428,4 +428,9 @@ func (h *Handler) buildMaintenanceProposal(t tableInfo, filerAddress string) *pl
|
|||||||
"table": t.TableName,
|
"table": t.TableName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if resourceGroup != "" {
|
||||||
|
proposal.Parameters["resource_group"] = &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_StringValue{StringValue: resourceGroup}}
|
||||||
|
proposal.Labels["resource_group"] = resourceGroup
|
||||||
|
}
|
||||||
|
return proposal
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,11 +96,36 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
SectionId: "resources",
|
||||||
|
Title: "Resource Groups",
|
||||||
|
Description: "Controls for fair proposal distribution across buckets or namespaces.",
|
||||||
|
Fields: []*plugin_pb.ConfigField{
|
||||||
|
{
|
||||||
|
Name: "resource_group_by",
|
||||||
|
Label: "Group Proposals By",
|
||||||
|
Description: "When set, detection emits proposals in round-robin order across the selected resource group.",
|
||||||
|
Placeholder: "none, bucket, namespace, or bucket_namespace",
|
||||||
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||||
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "max_tables_per_resource_group",
|
||||||
|
Label: "Max Tables Per Group",
|
||||||
|
Description: "Optional cap on how many proposals a single resource group can receive in one detection run. Zero disables the cap.",
|
||||||
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||||
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||||
|
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||||
"bucket_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
"bucket_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||||
"namespace_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
"namespace_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||||
"table_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
"table_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||||
|
"resource_group_by": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: resourceGroupNone}},
|
||||||
|
"max_tables_per_resource_group": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||||
@@ -283,6 +308,10 @@ func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionReq
|
|||||||
bucketFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "bucket_filter", ""))
|
bucketFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "bucket_filter", ""))
|
||||||
namespaceFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "namespace_filter", ""))
|
namespaceFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "namespace_filter", ""))
|
||||||
tableFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "table_filter", ""))
|
tableFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "table_filter", ""))
|
||||||
|
resourceGroups, err := readResourceGroupConfig(request.GetAdminConfigValues())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid admin resource group config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to filer — try each address until one succeeds.
|
// Connect to filer — try each address until one succeeds.
|
||||||
filerAddress, conn, err := h.connectToFiler(ctx, filerAddresses)
|
filerAddress, conn, err := h.connectToFiler(ctx, filerAddresses)
|
||||||
@@ -293,7 +322,11 @@ func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionReq
|
|||||||
filerClient := filer_pb.NewSeaweedFilerClient(conn)
|
filerClient := filer_pb.NewSeaweedFilerClient(conn)
|
||||||
|
|
||||||
maxResults := int(request.MaxResults)
|
maxResults := int(request.MaxResults)
|
||||||
tables, err := h.scanTablesForMaintenance(ctx, filerClient, workerConfig, bucketFilter, namespaceFilter, tableFilter, maxResults)
|
scanLimit := maxResults
|
||||||
|
if resourceGroups.enabled() {
|
||||||
|
scanLimit = 0
|
||||||
|
}
|
||||||
|
tables, err := h.scanTablesForMaintenance(ctx, filerClient, workerConfig, bucketFilter, namespaceFilter, tableFilter, scanLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("scan_error", fmt.Sprintf("error scanning tables: %v", err), nil))
|
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("scan_error", fmt.Sprintf("error scanning tables: %v", err), nil))
|
||||||
return fmt.Errorf("scan tables: %w", err)
|
return fmt.Errorf("scan tables: %w", err)
|
||||||
@@ -305,15 +338,11 @@ func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionReq
|
|||||||
"tables_found": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(tables))}},
|
"tables_found": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(tables))}},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
hasMore := false
|
tables, hasMore := selectTablesByResourceGroup(tables, resourceGroups, maxResults)
|
||||||
if maxResults > 0 && len(tables) > maxResults {
|
|
||||||
hasMore = true
|
|
||||||
tables = tables[:maxResults]
|
|
||||||
}
|
|
||||||
|
|
||||||
proposals := make([]*plugin_pb.JobProposal, 0, len(tables))
|
proposals := make([]*plugin_pb.JobProposal, 0, len(tables))
|
||||||
for _, t := range tables {
|
for _, t := range tables {
|
||||||
proposal := h.buildMaintenanceProposal(t, filerAddress)
|
proposal := h.buildMaintenanceProposal(t, filerAddress, resourceGroupKey(t, resourceGroups.GroupBy))
|
||||||
proposals = append(proposals, proposal)
|
proposals = append(proposals, proposal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ func TestBuildMaintenanceProposal(t *testing.T) {
|
|||||||
Metadata: meta,
|
Metadata: meta,
|
||||||
}
|
}
|
||||||
|
|
||||||
proposal := handler.buildMaintenanceProposal(info, "localhost:8888")
|
proposal := handler.buildMaintenanceProposal(info, "localhost:8888", "my-bucket")
|
||||||
|
|
||||||
expectedDedupe := "iceberg_maintenance:my-bucket/analytics/events"
|
expectedDedupe := "iceberg_maintenance:my-bucket/analytics/events"
|
||||||
if proposal.DedupeKey != expectedDedupe {
|
if proposal.DedupeKey != expectedDedupe {
|
||||||
@@ -241,6 +241,93 @@ func TestBuildMaintenanceProposal(t *testing.T) {
|
|||||||
if readStringConfig(proposal.Parameters, "filer_address", "") != "localhost:8888" {
|
if readStringConfig(proposal.Parameters, "filer_address", "") != "localhost:8888" {
|
||||||
t.Error("expected filer_address=localhost:8888 in parameters")
|
t.Error("expected filer_address=localhost:8888 in parameters")
|
||||||
}
|
}
|
||||||
|
if readStringConfig(proposal.Parameters, "resource_group", "") != "my-bucket" {
|
||||||
|
t.Error("expected resource_group=my-bucket in parameters")
|
||||||
|
}
|
||||||
|
if proposal.Labels["resource_group"] != "my-bucket" {
|
||||||
|
t.Error("expected resource_group label to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadResourceGroupConfig(t *testing.T) {
|
||||||
|
cfg, err := readResourceGroupConfig(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readResourceGroupConfig(nil): %v", err)
|
||||||
|
}
|
||||||
|
if cfg.GroupBy != resourceGroupNone {
|
||||||
|
t.Fatalf("expected default groupBy=%q, got %q", resourceGroupNone, cfg.GroupBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = readResourceGroupConfig(map[string]*plugin_pb.ConfigValue{
|
||||||
|
"resource_group_by": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "bucket_namespace"}},
|
||||||
|
"max_tables_per_resource_group": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readResourceGroupConfig(valid): %v", err)
|
||||||
|
}
|
||||||
|
if cfg.GroupBy != resourceGroupBucketNamespace {
|
||||||
|
t.Fatalf("expected bucket_namespace grouping, got %q", cfg.GroupBy)
|
||||||
|
}
|
||||||
|
if cfg.MaxTablesPerGroup != 2 {
|
||||||
|
t.Fatalf("expected max tables per group=2, got %d", cfg.MaxTablesPerGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := readResourceGroupConfig(map[string]*plugin_pb.ConfigValue{
|
||||||
|
"resource_group_by": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "invalid"}},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected invalid resource_group_by to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := readResourceGroupConfig(map[string]*plugin_pb.ConfigValue{
|
||||||
|
"max_tables_per_resource_group": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected group cap without grouping to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectTablesByResourceGroupRoundRobin(t *testing.T) {
|
||||||
|
tables := []tableInfo{
|
||||||
|
{BucketName: "a", Namespace: "ns1", TableName: "t1"},
|
||||||
|
{BucketName: "a", Namespace: "ns1", TableName: "t2"},
|
||||||
|
{BucketName: "b", Namespace: "ns2", TableName: "t3"},
|
||||||
|
{BucketName: "b", Namespace: "ns2", TableName: "t4"},
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, hasMore := selectTablesByResourceGroup(tables, resourceGroupConfig{
|
||||||
|
GroupBy: resourceGroupBucket,
|
||||||
|
}, 3)
|
||||||
|
if !hasMore {
|
||||||
|
t.Fatal("expected hasMore when maxResults truncates the selection")
|
||||||
|
}
|
||||||
|
if len(selected) != 3 {
|
||||||
|
t.Fatalf("expected 3 selected tables, got %d", len(selected))
|
||||||
|
}
|
||||||
|
if selected[0].BucketName != "a" || selected[1].BucketName != "b" || selected[2].BucketName != "a" {
|
||||||
|
t.Fatalf("expected round-robin bucket order [a, b, a], got [%s, %s, %s]", selected[0].BucketName, selected[1].BucketName, selected[2].BucketName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectTablesByResourceGroupCap(t *testing.T) {
|
||||||
|
tables := []tableInfo{
|
||||||
|
{BucketName: "a", Namespace: "ns1", TableName: "t1"},
|
||||||
|
{BucketName: "a", Namespace: "ns1", TableName: "t2"},
|
||||||
|
{BucketName: "b", Namespace: "ns2", TableName: "t3"},
|
||||||
|
{BucketName: "b", Namespace: "ns2", TableName: "t4"},
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, hasMore := selectTablesByResourceGroup(tables, resourceGroupConfig{
|
||||||
|
GroupBy: resourceGroupBucket,
|
||||||
|
MaxTablesPerGroup: 1,
|
||||||
|
}, 0)
|
||||||
|
if !hasMore {
|
||||||
|
t.Fatal("expected hasMore when per-group cap omits tables")
|
||||||
|
}
|
||||||
|
if len(selected) != 2 {
|
||||||
|
t.Fatalf("expected 2 selected tables, got %d", len(selected))
|
||||||
|
}
|
||||||
|
if selected[0].BucketName != "a" || selected[1].BucketName != "b" {
|
||||||
|
t.Fatalf("expected one table per bucket, got [%s, %s]", selected[0].BucketName, selected[1].BucketName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManifestRewritePathConsistency(t *testing.T) {
|
func TestManifestRewritePathConsistency(t *testing.T) {
|
||||||
|
|||||||
111
weed/plugin/worker/iceberg/resource_groups.go
Normal file
111
weed/plugin/worker/iceberg/resource_groups.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package iceberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
resourceGroupNone = "none"
|
||||||
|
resourceGroupBucket = "bucket"
|
||||||
|
resourceGroupNamespace = "namespace"
|
||||||
|
resourceGroupBucketNamespace = "bucket_namespace"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resourceGroupConfig struct {
|
||||||
|
GroupBy string
|
||||||
|
MaxTablesPerGroup int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func readResourceGroupConfig(values map[string]*plugin_pb.ConfigValue) (resourceGroupConfig, error) {
|
||||||
|
groupBy := strings.TrimSpace(strings.ToLower(readStringConfig(values, "resource_group_by", "")))
|
||||||
|
if groupBy == "" {
|
||||||
|
groupBy = resourceGroupNone
|
||||||
|
}
|
||||||
|
|
||||||
|
switch groupBy {
|
||||||
|
case resourceGroupNone, resourceGroupBucket, resourceGroupNamespace, resourceGroupBucketNamespace:
|
||||||
|
default:
|
||||||
|
return resourceGroupConfig{}, fmt.Errorf("invalid resource_group_by %q (valid: none, bucket, namespace, bucket_namespace)", groupBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTablesPerGroup := readInt64Config(values, "max_tables_per_resource_group", 0)
|
||||||
|
if maxTablesPerGroup < 0 {
|
||||||
|
return resourceGroupConfig{}, fmt.Errorf("max_tables_per_resource_group must be >= 0, got %d", maxTablesPerGroup)
|
||||||
|
}
|
||||||
|
if groupBy == resourceGroupNone && maxTablesPerGroup > 0 {
|
||||||
|
return resourceGroupConfig{}, fmt.Errorf("max_tables_per_resource_group requires resource_group_by to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
return resourceGroupConfig{
|
||||||
|
GroupBy: groupBy,
|
||||||
|
MaxTablesPerGroup: maxTablesPerGroup,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c resourceGroupConfig) enabled() bool {
|
||||||
|
return c.GroupBy != "" && c.GroupBy != resourceGroupNone
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceGroupKey(info tableInfo, groupBy string) string {
|
||||||
|
switch groupBy {
|
||||||
|
case resourceGroupBucket:
|
||||||
|
return info.BucketName
|
||||||
|
case resourceGroupNamespace:
|
||||||
|
return info.Namespace
|
||||||
|
case resourceGroupBucketNamespace:
|
||||||
|
return info.BucketName + "/" + info.Namespace
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectTablesByResourceGroup(tables []tableInfo, cfg resourceGroupConfig, maxResults int) ([]tableInfo, bool) {
|
||||||
|
if !cfg.enabled() {
|
||||||
|
if maxResults > 0 && len(tables) > maxResults {
|
||||||
|
return tables[:maxResults], true
|
||||||
|
}
|
||||||
|
return tables, false
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped := make(map[string][]tableInfo)
|
||||||
|
groupOrder := make([]string, 0)
|
||||||
|
for _, table := range tables {
|
||||||
|
key := resourceGroupKey(table, cfg.GroupBy)
|
||||||
|
if _, ok := grouped[key]; !ok {
|
||||||
|
groupOrder = append(groupOrder, key)
|
||||||
|
}
|
||||||
|
grouped[key] = append(grouped[key], table)
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := make([]tableInfo, 0, len(tables))
|
||||||
|
selectedPerGroup := make(map[string]int64)
|
||||||
|
for {
|
||||||
|
progress := false
|
||||||
|
for _, key := range groupOrder {
|
||||||
|
if maxResults > 0 && len(selected) >= maxResults {
|
||||||
|
return selected, len(selected) < len(tables)
|
||||||
|
}
|
||||||
|
if cfg.MaxTablesPerGroup > 0 && selectedPerGroup[key] >= cfg.MaxTablesPerGroup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
queue := grouped[key]
|
||||||
|
if len(queue) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = append(selected, queue[0])
|
||||||
|
grouped[key] = queue[1:]
|
||||||
|
selectedPerGroup[key]++
|
||||||
|
progress = true
|
||||||
|
}
|
||||||
|
if !progress {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected, len(selected) < len(tables)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user