mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-08 19:27:14 +00:00
e9e2e30278
63b46cd6made a multi-inbound client create apply its inbounds concurrently, andd34ec97fdid the same for the single-client update, delete and detach. The bulk operations were never converted, so they still walked their inbounds in a plain sequential loop with a node RPC in each iteration — and those are what the panel actually calls for a multi-select delete or an enable/disable, which is why editing and deleting still felt slow on a master with several nodes. Measured with a node runtime injecting 100ms per RPC, one client per node: nodes=1 update=101ms bulkSetEnable=101ms bulkAdjust=101ms bulkDelete=101ms nodes=3 update=102ms bulkSetEnable=302ms bulkAdjust=304ms bulkDelete=303ms nodes=5 update=203ms bulkSetEnable=504ms bulkAdjust=504ms bulkDelete=504ms after, all of them track the single-client ops: nodes=3 bulkSetEnable=103ms bulkAdjust=101ms bulkDelete=101ms nodes=5 bulkSetEnable=202ms bulkAdjust=202ms bulkDelete=202ms Generalize the fanout into fanoutInboundResults over an arbitrary per-inbound result type and route six loops through it: BulkDelete, BulkSetEnable, BulkAdjust, BulkDetach, BulkAttach, BulkCreate, plus applyClientFieldByEmail — the field edit behind the Telegram bot's enable/limit/expiry buttons and the LDAP job. Each keeps its preparation sequential and overlaps only the node pushes, inheriting the same concurrency cap and per-inbound panic recovery. Two ordering details the sequential loops got for free and the fanout must do itself: the three loops that ranged a map now walk sortedInboundIds, so which inbound wins a per-email skip reason is the lowest id instead of whatever the map yielded; and BulkAttach de-duplicates a repeated inbound id up front, because the second pass used to see the client the first pass had just added. The allocating paths stay serial when a tunnel inbound is involved. WireGuard and AmneziaWG pick a free peer address by reading every inbound's used-set before they write, so two overlapping allocations hand out the same address and the in-transaction re-check refuses the loser — a bulk create of two clients onto two wg inbounds returned created=1. addFanoutLimit drops those batches back to one at a time; every other protocol keeps the full cap. Eight tests: seven barrier tests that a sequential caller cannot satisfy (peak pushes in flight is 1 without the change, 4 with it), and one that pins the tunnel allocation.
229 lines
8.5 KiB
Go
229 lines
8.5 KiB
Go
package service
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// The bulk ops walked their inbounds one node round-trip at a time, so a client
|
|
// spanning several nodes cost the SUM of every node's latency. Each test below
|
|
// times out on the barrier unless the pushes overlap.
|
|
|
|
func TestBulkDeleteAcrossNodesPushesConcurrently(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
|
|
const nodes = inboundFanoutConcurrency + 1
|
|
bar := newApplyBarrier(inboundFanoutConcurrency)
|
|
seedClientAcrossNodes(t, bar, nodes, 46101, "bulkdel@x", "aaaaaaaa-1111-2222-3333-444444444444")
|
|
|
|
bar.arm()
|
|
if _, _, err := (&ClientService{}).BulkDelete(&InboundService{}, []string{"bulkdel@x"}, false); err != nil {
|
|
t.Fatalf("BulkDelete across %d node inbounds: %v", nodes, err)
|
|
}
|
|
if got := bar.deleteClient.Load(); got == 0 {
|
|
t.Fatalf("no node push reached the barrier at all")
|
|
}
|
|
if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
|
|
t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
|
|
got, inboundFanoutConcurrency, bar.expired.Load())
|
|
}
|
|
}
|
|
|
|
func TestBulkSetEnableAcrossNodesPushesConcurrently(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
|
|
const nodes = inboundFanoutConcurrency + 1
|
|
bar := newApplyBarrier(inboundFanoutConcurrency)
|
|
seedClientAcrossNodes(t, bar, nodes, 46201, "bulkena@x", "bbbbbbbb-1111-2222-3333-444444444444")
|
|
|
|
bar.arm()
|
|
if _, _, err := (&ClientService{}).BulkSetEnable(&InboundService{}, []string{"bulkena@x"}, false); err != nil {
|
|
t.Fatalf("BulkSetEnable across %d node inbounds: %v", nodes, err)
|
|
}
|
|
if got := bar.updateUser.Load(); got == 0 {
|
|
t.Fatalf("no node push reached the barrier at all")
|
|
}
|
|
if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
|
|
t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
|
|
got, inboundFanoutConcurrency, bar.expired.Load())
|
|
}
|
|
}
|
|
|
|
func TestBulkAdjustAcrossNodesPushesConcurrently(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
|
|
const nodes = inboundFanoutConcurrency + 1
|
|
const email = "bulkadj@x"
|
|
bar := newApplyBarrier(inboundFanoutConcurrency)
|
|
mgr := useTestRuntimeManager(t)
|
|
ids := fanoutNodeInbounds(t, mgr, bar, nodes, 46301)
|
|
// An expiry to extend, or BulkAdjust reports the client ineligible and
|
|
// never reaches a node at all.
|
|
if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
|
|
Client: model.Client{
|
|
Email: email, ID: "cccccccc-1111-2222-3333-444444444444", SubID: "sub-" + email,
|
|
Enable: true, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
|
|
},
|
|
InboundIds: ids,
|
|
}); err != nil {
|
|
t.Fatalf("seed Create across %d node inbounds: %v", nodes, err)
|
|
}
|
|
|
|
bar.arm()
|
|
if _, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{email}, 1, 0, ""); err != nil {
|
|
t.Fatalf("BulkAdjust across %d node inbounds: %v", nodes, err)
|
|
}
|
|
if got := bar.updateUser.Load(); got == 0 {
|
|
t.Fatalf("no node push reached the barrier at all")
|
|
}
|
|
if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
|
|
t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
|
|
got, inboundFanoutConcurrency, bar.expired.Load())
|
|
}
|
|
}
|
|
|
|
func TestBulkAttachAcrossNodesPushesConcurrently(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
|
|
const nodes = inboundFanoutConcurrency + 1
|
|
const email = "bulkatt@x"
|
|
bar := newApplyBarrier(inboundFanoutConcurrency)
|
|
mgr := useTestRuntimeManager(t)
|
|
// Seeded on the first inbound only, so the other nodes are all attach work.
|
|
ids := fanoutNodeInbounds(t, mgr, bar, nodes, 46501)
|
|
if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
|
|
Client: model.Client{Email: email, ID: "eeeeeeee-1111-2222-3333-444444444444", SubID: "sub-" + email, Enable: true},
|
|
InboundIds: ids[:1],
|
|
}); err != nil {
|
|
t.Fatalf("seed Create: %v", err)
|
|
}
|
|
|
|
bar.arm()
|
|
if _, _, err := (&ClientService{}).BulkAttach(&InboundService{}, []string{email}, ids[1:]); err != nil {
|
|
t.Fatalf("BulkAttach across %d node inbounds: %v", nodes-1, err)
|
|
}
|
|
if got := bar.addClient.Load(); got == 0 {
|
|
t.Fatalf("no node push reached the barrier at all")
|
|
}
|
|
if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
|
|
t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
|
|
got, inboundFanoutConcurrency, bar.expired.Load())
|
|
}
|
|
}
|
|
|
|
func TestBulkCreateAcrossNodesPushesConcurrently(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
|
|
const nodes = inboundFanoutConcurrency + 1
|
|
bar := newApplyBarrier(inboundFanoutConcurrency)
|
|
mgr := useTestRuntimeManager(t)
|
|
ids := fanoutNodeInbounds(t, mgr, bar, nodes, 46601)
|
|
|
|
bar.arm()
|
|
if _, _, err := (&ClientService{}).BulkCreate(&InboundService{}, []ClientCreatePayload{{
|
|
Client: model.Client{Email: "bulknew@x", ID: "ffffffff-1111-2222-3333-444444444444", SubID: "sub-bulknew", Enable: true},
|
|
InboundIds: ids,
|
|
}}); err != nil {
|
|
t.Fatalf("BulkCreate across %d node inbounds: %v", nodes, err)
|
|
}
|
|
if got := bar.addClient.Load(); got == 0 {
|
|
t.Fatalf("no node push reached the barrier at all")
|
|
}
|
|
if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
|
|
t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
|
|
got, inboundFanoutConcurrency, bar.expired.Load())
|
|
}
|
|
}
|
|
|
|
func TestBulkDetachAcrossNodesPushesConcurrently(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
|
|
const nodes = inboundFanoutConcurrency + 1
|
|
const email = "bulkdet@x"
|
|
bar := newApplyBarrier(inboundFanoutConcurrency)
|
|
recID := seedClientAcrossNodes(t, bar, nodes, 46401, email, "dddddddd-1111-2222-3333-444444444444")
|
|
ids, err := (&ClientService{}).GetInboundIdsForRecord(recID)
|
|
if err != nil {
|
|
t.Fatalf("GetInboundIdsForRecord: %v", err)
|
|
}
|
|
|
|
bar.arm()
|
|
if _, _, err := (&ClientService{}).BulkDetach(&InboundService{}, []string{email}, ids); err != nil {
|
|
t.Fatalf("BulkDetach across %d node inbounds: %v", nodes, err)
|
|
}
|
|
if got := bar.deleteUser.Load(); got == 0 {
|
|
t.Fatalf("no node push reached the barrier at all")
|
|
}
|
|
if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
|
|
t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
|
|
got, inboundFanoutConcurrency, bar.expired.Load())
|
|
}
|
|
}
|
|
|
|
// TestApplyClientFieldAcrossNodesPushesConcurrently covers the field-edit path
|
|
// the Telegram bot and the LDAP job use (enable toggle, ip/expiry/traffic reset).
|
|
func TestApplyClientFieldAcrossNodesPushesConcurrently(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
|
|
const nodes = inboundFanoutConcurrency + 1
|
|
const email = "fieldedit@x"
|
|
bar := newApplyBarrier(inboundFanoutConcurrency)
|
|
seedClientAcrossNodes(t, bar, nodes, 46701, email, "99999999-1111-2222-3333-444444444444")
|
|
|
|
bar.arm()
|
|
if _, err := (&ClientService{}).ResetClientIpLimitByEmail(&InboundService{}, email, 3); err != nil {
|
|
t.Fatalf("ResetClientIpLimitByEmail across %d node inbounds: %v", nodes, err)
|
|
}
|
|
if got := bar.updateUser.Load(); got == 0 {
|
|
t.Fatalf("no node push reached the barrier at all")
|
|
}
|
|
if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
|
|
t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
|
|
got, inboundFanoutConcurrency, bar.expired.Load())
|
|
}
|
|
}
|
|
|
|
// TestBulkCreateSerializesTunnelAddressAllocation pins that a bulk create over
|
|
// WireGuard inbounds does not overlap: allocation reads every inbound's used-set
|
|
// before writing, so two concurrent picks collide and the second is refused.
|
|
func TestBulkCreateSerializesTunnelAddressAllocation(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
db := database.GetDB()
|
|
|
|
ids := make([]int, 0, 2)
|
|
for i := range 2 {
|
|
ib := &model.Inbound{
|
|
UserId: 1, Enable: true, Port: 51820 + i,
|
|
Tag: fmt.Sprintf("wg-%d", i), Protocol: model.WireGuard,
|
|
Settings: `{"clients":[],"mtu":1420,"secretKey":"QO3O1V0m0Sm1yQ0hVvJ0kM0kQe0mYq0Wc0Zk0Xs0Zm8=","peers":[]}`,
|
|
}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("create wg inbound: %v", err)
|
|
}
|
|
ids = append(ids, ib.Id)
|
|
}
|
|
|
|
res, _, err := (&ClientService{}).BulkCreate(&InboundService{}, []ClientCreatePayload{
|
|
{Client: model.Client{Email: "a@wg", SubID: "sa", Enable: true}, InboundIds: []int{ids[0]}},
|
|
{Client: model.Client{Email: "b@wg", SubID: "sb", Enable: true}, InboundIds: []int{ids[1]}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BulkCreate over two wg inbounds: %v", err)
|
|
}
|
|
if res.Created != 2 {
|
|
t.Fatalf("created = %d, want 2 — concurrent allocation handed out one address twice: %+v", res.Created, res.Skipped)
|
|
}
|
|
}
|