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.
This commit is contained in:
Sanaei
2026-09-07 02:19:16 +02:00
parent f2cf589947
commit e9e2e30278
5 changed files with 426 additions and 28 deletions
+9 -6
View File
@@ -1343,6 +1343,9 @@ func (s *ClientService) applyClientFieldByEmail(inboundSvc *InboundService, clie
needRestart := false
found := false
// Built before any inbound is written, as in Update: only the applies
// overlap, so one node's round-trip no longer waits on the previous one.
applies := make([]inboundApply, 0, len(inboundIds))
for _, ibId := range inboundIds {
inbound, gErr := inboundSvc.GetInbound(ibId)
if gErr != nil {
@@ -1379,17 +1382,17 @@ func (s *ClientService) applyClientFieldByEmail(inboundSvc *InboundService, clie
return needRestart, mErr
}
inbound.Settings = string(modifiedSettings)
nr, uErr := s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
if uErr != nil {
return needRestart, uErr
}
needRestart = needRestart || nr
data := inbound
applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
return s.UpdateInboundClient(inboundSvc, data, clientEmail)
}})
}
if !found {
return needRestart, common.NewError("Client Not Found For Email:", clientEmail)
}
return needRestart, nil
nr, applyErr := fanoutInboundApplies(applies)
return needRestart || nr, applyErr
}
func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, clientEmail string, count int) (bool, error) {