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.
179 lines
5.8 KiB
Go
179 lines
5.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// applyBarrierRuntime holds every armed node push until fanout of them are
|
|
// inside it at once; a sequential caller only ever reaches one and times out.
|
|
// It stays pass-through until arm() so a test can seed its clients first.
|
|
type applyBarrierRuntime struct {
|
|
fakeNodeRuntime
|
|
fanout int32
|
|
armed atomic.Bool
|
|
inFlight atomic.Int32
|
|
maxPar atomic.Int32
|
|
release chan struct{}
|
|
freed atomic.Bool
|
|
expired atomic.Bool
|
|
}
|
|
|
|
func newApplyBarrier(fanout int32) *applyBarrierRuntime {
|
|
return &applyBarrierRuntime{fanout: fanout, release: make(chan struct{})}
|
|
}
|
|
|
|
func (b *applyBarrierRuntime) arm() { b.armed.Store(true) }
|
|
|
|
func (b *applyBarrierRuntime) free() {
|
|
if b.freed.CompareAndSwap(false, true) {
|
|
close(b.release)
|
|
}
|
|
}
|
|
|
|
func (b *applyBarrierRuntime) wait() {
|
|
if !b.armed.Load() {
|
|
return
|
|
}
|
|
n := b.inFlight.Add(1)
|
|
for {
|
|
peak := b.maxPar.Load()
|
|
if n <= peak || b.maxPar.CompareAndSwap(peak, n) {
|
|
break
|
|
}
|
|
}
|
|
if n == b.fanout {
|
|
b.free()
|
|
}
|
|
select {
|
|
case <-b.release:
|
|
case <-time.After(5 * time.Second):
|
|
// Release everyone on the first timeout so a sequential regression
|
|
// fails once instead of stalling for fanout x the wait.
|
|
b.expired.Store(true)
|
|
b.free()
|
|
}
|
|
b.inFlight.Add(-1)
|
|
}
|
|
|
|
func (b *applyBarrierRuntime) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, c model.Client) error {
|
|
b.wait()
|
|
return b.fakeNodeRuntime.UpdateUser(ctx, ib, oldEmail, c)
|
|
}
|
|
|
|
func (b *applyBarrierRuntime) AddClient(ctx context.Context, ib *model.Inbound, c model.Client) error {
|
|
b.wait()
|
|
return b.fakeNodeRuntime.AddClient(ctx, ib, c)
|
|
}
|
|
|
|
func (b *applyBarrierRuntime) DeleteClient(ctx context.Context, email string) error {
|
|
b.wait()
|
|
return b.fakeNodeRuntime.DeleteClient(ctx, email)
|
|
}
|
|
|
|
func (b *applyBarrierRuntime) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
|
|
b.wait()
|
|
return b.fakeNodeRuntime.DeleteUser(ctx, ib, email)
|
|
}
|
|
|
|
// seedClientAcrossNodes creates one client on nodes separate node inbounds and
|
|
// returns its record id, with the barrier still disarmed.
|
|
func seedClientAcrossNodes(t *testing.T, bar *applyBarrierRuntime, nodes int, basePort int, email, uuid string) int {
|
|
t.Helper()
|
|
mgr := useTestRuntimeManager(t)
|
|
ids := fanoutNodeInbounds(t, mgr, bar, nodes, basePort)
|
|
if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
|
|
Client: model.Client{Email: email, ID: uuid, SubID: "sub-" + email, Enable: true},
|
|
InboundIds: ids,
|
|
}); err != nil {
|
|
t.Fatalf("seed Create across %d node inbounds: %v", nodes, err)
|
|
}
|
|
return lookupClientRecord(t, email).Id
|
|
}
|
|
|
|
// TestUpdateAcrossNodesPushesConcurrently pins that editing a client attached to
|
|
// several node inbounds pushes to them at once. Sequentially the per-node
|
|
// round-trips add up, so an edit on a multi-node master cost one RPC per node.
|
|
func TestUpdateAcrossNodesPushesConcurrently(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
|
|
const nodes = inboundFanoutConcurrency + 1
|
|
const uuid = "aaaaaaaa-1111-2222-3333-444444444444"
|
|
bar := newApplyBarrier(inboundFanoutConcurrency)
|
|
recID := seedClientAcrossNodes(t, bar, nodes, 45101, "upfan@x", uuid)
|
|
|
|
bar.arm()
|
|
if _, err := (&ClientService{}).Update(&InboundService{}, recID, model.Client{
|
|
Email: "upfan@x", ID: uuid, SubID: "sub-upfan@x", Enable: true, Comment: "edited",
|
|
}, 0); err != nil {
|
|
t.Fatalf("Update across %d node inbounds: %v", nodes, err)
|
|
}
|
|
|
|
if got := bar.updateUser.Load(); got != nodes {
|
|
t.Fatalf("UpdateUser pushes = %d, want %d", got, nodes)
|
|
}
|
|
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())
|
|
}
|
|
}
|
|
|
|
// TestDeleteAcrossNodesPushesConcurrently is the delete-side twin of the update
|
|
// test above: removing a client must not cost one node round-trip per node.
|
|
func TestDeleteAcrossNodesPushesConcurrently(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
|
|
const nodes = inboundFanoutConcurrency + 1
|
|
const uuid = "bbbbbbbb-1111-2222-3333-444444444444"
|
|
bar := newApplyBarrier(inboundFanoutConcurrency)
|
|
recID := seedClientAcrossNodes(t, bar, nodes, 45201, "delfan@x", uuid)
|
|
|
|
bar.arm()
|
|
if _, err := (&ClientService{}).Delete(&InboundService{}, recID, false); err != nil {
|
|
t.Fatalf("Delete across %d node inbounds: %v", nodes, err)
|
|
}
|
|
|
|
if got := bar.deleteClient.Load(); got != nodes {
|
|
t.Fatalf("DeleteClient pushes = %d, want %d", got, nodes)
|
|
}
|
|
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())
|
|
}
|
|
}
|
|
|
|
// TestDetachAcrossNodesPushesConcurrently covers the third sequential loop: a
|
|
// bulk detach walks the same per-inbound node push as update and delete.
|
|
func TestDetachAcrossNodesPushesConcurrently(t *testing.T) {
|
|
setupBulkDB(t)
|
|
startSerializedWriter(t)
|
|
|
|
const nodes = inboundFanoutConcurrency + 1
|
|
const uuid = "cccccccc-1111-2222-3333-444444444444"
|
|
bar := newApplyBarrier(inboundFanoutConcurrency)
|
|
recID := seedClientAcrossNodes(t, bar, nodes, 45301, "detfan@x", uuid)
|
|
ids, err := (&ClientService{}).GetInboundIdsForRecord(recID)
|
|
if err != nil {
|
|
t.Fatalf("GetInboundIdsForRecord: %v", err)
|
|
}
|
|
|
|
bar.arm()
|
|
if _, err := (&ClientService{}).Detach(&InboundService{}, recID, ids); err != nil {
|
|
t.Fatalf("Detach across %d node inbounds: %v", nodes, err)
|
|
}
|
|
|
|
if got := bar.deleteUser.Load(); got != nodes {
|
|
t.Fatalf("DeleteUser pushes = %d, want %d", got, nodes)
|
|
}
|
|
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())
|
|
}
|
|
}
|