Browse Source

perf(clients): take one email snapshot per client fan-out, not one per inbound (#6091)

Create and Attach called the exported AddInboundClient once per target
inbound, and that wrapper passes a nil email→subId map, so every iteration
re-ran getAllEmailSubIDs -- a JSON_EACH expansion over the settings blob of
every inbound in the panel. Adding one client to 24 inbounds on a panel with
~300 users meant 24 full expansions of ~7k rows to answer the same question.

Hoist the snapshot above the loop and call the unexported addInboundClient
with it, exactly as BulkAttach (client_bulk.go:63) and BulkCreate
(client_bulk.go:1151) already do. The snapshot goes stale from the second
inbound onward, but the identity being added is the same on every iteration,
so its own entry can only ever match itself -- checkEmailsExistForClients
accepts an email whose stored subId equals the incoming one, and an absent
entry is accepted too.

This is the database half of #6091. The dominant cost there is the other
half -- one synchronous 10s-capped node round-trip per remote inbound,
which multiplies again on chained nodes -- and that needs the push batched
per node rather than per inbound; left for a separate change.
Sanaei 15 hours ago
parent
commit
c3967e57dc

+ 84 - 0
internal/web/service/client_create_fanout_test.go

@@ -0,0 +1,84 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestCreateAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const uuid = "bbbbbbbb-1111-2222-3333-555555555555"
+	ids := make([]int, 0, 6)
+	for i := range 6 {
+		ib := mkInbound(t, 23001+i, model.VLESS, `{"clients":[]}`)
+		ids = append(ids, ib.Id)
+	}
+
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+		Client:     model.Client{Email: "fan@x", ID: uuid, SubID: "sub-fan", Enable: true},
+		InboundIds: ids,
+	}); err != nil {
+		t.Fatalf("Create across %d inbounds: %v", len(ids), err)
+	}
+
+	if n := countClientRecords(t); n != 1 {
+		t.Fatalf("client records = %d, want 1", n)
+	}
+	rec := lookupClientRecord(t, "fan@x")
+	if rec.UUID != uuid || rec.SubID != "sub-fan" {
+		t.Fatalf("record = {uuid:%q sub:%q}, want {%q sub-fan}", rec.UUID, rec.SubID, uuid)
+	}
+	for _, id := range ids {
+		if !settingsHoldUUID(t, inboundSvc, id, uuid) {
+			t.Fatalf("inbound %d settings missing the client", id)
+		}
+	}
+
+	linked, err := svc.GetInboundIdsForRecord(rec.Id)
+	if err != nil {
+		t.Fatalf("GetInboundIdsForRecord: %v", err)
+	}
+	if len(linked) != len(ids) {
+		t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
+	}
+}
+
+func TestAttachAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	first := mkInbound(t, 23101, model.VLESS, `{"clients":[]}`)
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+		Client:     model.Client{Email: "att@x", ID: "cccccccc-1111-2222-3333-666666666666", SubID: "sub-att", Enable: true},
+		InboundIds: []int{first.Id},
+	}); err != nil {
+		t.Fatalf("seed Create: %v", err)
+	}
+	rec := lookupClientRecord(t, "att@x")
+
+	ids := []int{first.Id}
+	for i := range 4 {
+		ib := mkInbound(t, 23102+i, model.VLESS, `{"clients":[]}`)
+		ids = append(ids, ib.Id)
+	}
+
+	if _, err := svc.Attach(inboundSvc, rec.Id, ids); err != nil {
+		t.Fatalf("Attach across %d inbounds: %v", len(ids), err)
+	}
+
+	if n := countClientRecords(t); n != 1 {
+		t.Fatalf("client records after attach = %d, want 1", n)
+	}
+	linked, err := svc.GetInboundIdsForRecord(rec.Id)
+	if err != nil {
+		t.Fatalf("GetInboundIdsForRecord: %v", err)
+	}
+	if len(linked) != len(ids) {
+		t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
+	}
+}

+ 14 - 4
internal/web/service/client_crud.go

@@ -110,6 +110,11 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 		}
 		}
 	}
 	}
 
 
+	emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
+	if sidErr != nil {
+		return false, sidErr
+	}
+
 	needRestart := false
 	needRestart := false
 	for _, ibId := range payload.InboundIds {
 	for _, ibId := range payload.InboundIds {
 		inbound, getErr := inboundSvc.GetInbound(ibId)
 		inbound, getErr := inboundSvc.GetInbound(ibId)
@@ -123,10 +128,10 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 		if mErr != nil {
 		if mErr != nil {
 			return needRestart, mErr
 			return needRestart, mErr
 		}
 		}
-		nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
+		nr, addErr := s.addInboundClient(inboundSvc, &model.Inbound{
 			Id:       ibId,
 			Id:       ibId,
 			Settings: string(settingsPayload),
 			Settings: string(settingsPayload),
-		})
+		}, emailSubIDs)
 		if addErr != nil {
 		if addErr != nil {
 			return needRestart, addErr
 			return needRestart, addErr
 		}
 		}
@@ -615,6 +620,11 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
 	clientWire.Flow = flow
 	clientWire.Flow = flow
 	clientWire.UpdatedAt = time.Now().UnixMilli()
 	clientWire.UpdatedAt = time.Now().UnixMilli()
 
 
+	emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
+	if sidErr != nil {
+		return false, sidErr
+	}
+
 	needRestart := false
 	needRestart := false
 	for _, ibId := range inboundIds {
 	for _, ibId := range inboundIds {
 		if _, attached := have[ibId]; attached {
 		if _, attached := have[ibId]; attached {
@@ -632,10 +642,10 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
 		if mErr != nil {
 		if mErr != nil {
 			return needRestart, mErr
 			return needRestart, mErr
 		}
 		}
-		nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
+		nr, addErr := s.addInboundClient(inboundSvc, &model.Inbound{
 			Id:       ibId,
 			Id:       ibId,
 			Settings: string(settingsPayload),
 			Settings: string(settingsPayload),
-		})
+		}, emailSubIDs)
 		if addErr != nil {
 		if addErr != nil {
 			return needRestart, addErr
 			return needRestart, addErr
 		}
 		}