Files
3x-ui/internal/web/service/client_update_fanout_test.go
T
Sanaei e9e2e30278 perf(clients): push a bulk client change to every node at once
63b46cd6 made a multi-inbound client create apply its inbounds concurrently,
and d34ec97f did 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.
2026-09-07 02:19:16 +02:00

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())
}
}