Files
3x-ui/internal/web/service/client_update_fanout_test.go
T
Sanaei a5e68f410f perf(node): bound the per-client node push and fan out the traffic reset
An operator with several nodes reported that editing a client or resetting
its traffic takes more than ten seconds on the master. Measured against real
Remote HTTP (fake node servers, one client per node), the healthy case is
already fast — 3 nodes: update 51ms, delete 51ms; 5 nodes: 102ms / 103ms —
but two things were not:

  - ResetTrafficByEmail still walked its inbounds one node round-trip after
    another: 152ms at 3 nodes, 253ms at 5, linear in node count.
  - Every per-client op blocked on the SLOWEST node's push. With one node
    answering in 3s, update/delete/reset all took 3003ms regardless of node
    count. A node that answers the 4s heartbeat probe but hangs on the push
    stays "online", so every edit waited on it up to remoteHTTPTimeout — the
    ten seconds in the report. More nodes only raise the odds one is sick.

The push is an immediacy optimisation, not the source of truth: every one of
these ops calls MarkNodeDirtyTx inside the transaction that commits the
change, before it pushes, and the node reconcile job converges a dirty node on
its next 5s tick by re-sending the inbound whose fingerprint was not advanced.
So bound the synchronous push with nodeClientPushTimeout = 4s — the budget the
heartbeat and traffic-sync jobs already treat as "responsive" — at the eight
node-branch push sites. A node that does not answer in time is left dirty and
converged a few seconds later instead of stalling the request; the tag-cache
list fetch inside resolveRemoteID shares the same budget.

Once one push in a batch has timed out, the rest of that inbound's batch now
stops pushing too, as AddInboundClient already did: the node is dirty and one
reconcile converges the whole inbound. Deleting three clients on one hung node
went from 30.08s (three remote timeouts) to 4.06s; at the 32-client push
threshold that is 128s of deadlines saved per inbound.

Fan the reset out through fanoutInboundApplies like the other client ops. Its
node propagation is still attempted whatever the node's status flag says, as
before, because nothing replays a traffic reset — the reconcile pushes inbound
config, not counters — so a node still serving after being marked offline must
receive it now or never.

Trade-offs stated plainly: a node that would have answered in 4–10s now falls
to the reconcile's full-inbound push, which on the node is a delete+add of the
inbound and drops its sessions there — the same fallback a failed 10s push
already used, now reached sooner. The reset stays best-effort with no retry
path, which predates this change. The response still reports success while a
timed-out node catches up; the pending-node badge is keyed off node status by
design, so only the warning log records it.

Tests: a barrier test that a sequential reset cannot satisfy; two tests against
a real runtime.Remote and an httptest node that hangs on the push, pinning that
an edit returns at the deadline (exactly one push reached the node, the node
is left dirty) and that a bulk delete stops after its first timed-out push.
All red without the change; the two hung-node tests pay their 4s deadline on
every run.
2026-09-07 14:24:14 +02:00

184 lines
6.0 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) ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error {
b.wait()
return b.fakeNodeRuntime.ResetClientTraffic(ctx, ib, email)
}
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())
}
}