From 4d8e2b48ffb510c69f262dde39dc19b6e3fb8626 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:20:27 +0100 Subject: [PATCH] perf(sub): make balancer member retagging linear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildBalancerConfig restarted the free tag-suffix search at 2 for every member, so member k formatted and looked up k tags and n members of one protocol cost n²/2 lookups; a balancer with many same-protocol members held a subscription request for minutes. The search now resumes per base tag where the previous member stopped, producing the same tags in linear time. --- internal/sub/json_service.go | 7 ++++- internal/sub/sub_balancer_test.go | 45 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index c608b928d..caa9cc1e9 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -531,6 +531,9 @@ func leastLoadCosts(balancer *model.SubBalancer, members []balMember) []any { func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entries []subConfigEntry, entryProxies [][]map[string]any) json_util.RawMessage { prefix := fmt.Sprintf("bal-%d-", balancer.Id) usedTags := make(map[string]bool) + // Next suffix to try per base: tags are only ever added, so every lower one + // is taken and resuming there keeps a node-sized member list linear. + nextSuffix := make(map[string]int) var proxies []json_util.RawMessage // Members in emission order with their owning inbound, so costs[] can // reference the exact retagged tags assigned here. @@ -546,9 +549,11 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie protocol, _ := outbound["protocol"].(string) base := prefix + balancerMemberSuffix(protocol) tag := base - for suffix := 2; usedTags[tag]; suffix++ { + suffix := max(nextSuffix[base], 2) + for ; usedTags[tag]; suffix++ { tag = fmt.Sprintf("%s-%d", base, suffix) } + nextSuffix[base] = suffix usedTags[tag] = true member := maps.Clone(outbound) member["tag"] = tag diff --git a/internal/sub/sub_balancer_test.go b/internal/sub/sub_balancer_test.go index dd827b1e4..80386e62d 100644 --- a/internal/sub/sub_balancer_test.go +++ b/internal/sub/sub_balancer_test.go @@ -2,11 +2,15 @@ package sub import ( "encoding/json" + "fmt" + "slices" "strings" "testing" + "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" ) func seedSubBalancer(t *testing.T, b *model.SubBalancer) *model.SubBalancer { @@ -508,3 +512,44 @@ func TestSubJson_BalancerCostsSkippedForNonLeastLoadStrategy(t *testing.T) { t.Fatalf("random balancer must never emit costs despite stored weights: %v", strategy) } } + +// A node authors the endpoint list of its inbounds, so the member count is +// not the admin's to bound; probing every taken suffix per member went quadratic. +func TestSubJson_BalancerManySameProtocolMembers(t *testing.T) { + members := make([]map[string]any, 100_000) + for i := range members { + members[i] = map[string]any{"protocol": "vless", "tag": "proxy"} + } + // A protocol whose base collides with a suffixed tag must still be skipped. + members[0]["protocol"] = "vless-3" + members[4]["protocol"] = "trojan" + + js := NewSubJsonService("", "", "", "", NewSubService("")) + done := make(chan json_util.RawMessage, 1) + go func() { + done <- js.buildBalancerConfig( + &model.SubBalancer{Id: 1, Remark: "many", Strategy: "random", InboundIds: []int{7}}, + []subConfigEntry{{id: 7}}, + [][]map[string]any{members}, + ) + }() + var raw json_util.RawMessage + select { + case raw = <-done: + case <-time.After(30 * time.Second): + t.Fatalf("retagging %d same-protocol members did not finish in 30s", len(members)) + } + + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("balancer doc is not JSON: %v", err) + } + tags := docOutboundTags(doc) + want := []string{"bal-1-vless-3", "bal-1-vless", "bal-1-vless-2", "bal-1-vless-4", "bal-1-trojan", "bal-1-vless-5"} + if len(tags) < len(members) || !slices.Equal(tags[:len(want)], want) { + t.Fatalf("first member tags = %v, want %v", tags[:min(len(tags), len(want))], want) + } + if got, last := tags[len(members)-1], fmt.Sprintf("bal-1-vless-%d", len(members)-1); got != last { + t.Fatalf("last member tag = %q, want %q", got, last) + } +}