From efaa73db3a5417844fbfe1d5997d0a5c0222a15b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 09:14:43 +0100 Subject: [PATCH] perf(tgbot): stop loading the whole inbound per client in usage listings Listing a Telegram account's clients ran CheckIsEnabledByEmail and describeAttachedInbounds for every client, and both loaded and parsed the client's entire inbound, so one message cost the number of clients times the size of their inbound's settings. CheckIsEnabledByEmail now reads the client's own record first, keeping the inbound parse only for legacy clients never normalized into the clients table, and describeAttachedInbounds takes every remark in one query without loading any settings. --- internal/web/service/client_inbound_apply.go | 6 ++ internal/web/service/inbound.go | 17 +++++ internal/web/service/tgbot/tgbot_client.go | 8 +-- .../service/tgbot/tgbot_review_fixes_test.go | 62 +++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index f1142d87f..67b6faebe 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -1351,6 +1351,12 @@ func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, traf } func (s *ClientService) CheckIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) { + // The client's own row answers without loading and parsing its whole + // inbound; legacy clients never normalized into `clients` fall through. + if rec, rErr := s.GetRecordByEmail(nil, clientEmail); rErr == nil && rec != nil { + return rec.Enable, nil + } + _, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail) if err != nil { return false, err diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 9a6d4e1ab..a0296cb8f 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1802,6 +1802,23 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) { return inbound, nil } +// GetInboundRemarks returns the remarks of the given inbounds keyed by id +// without loading their settings; ids that do not exist are absent. +func (s *InboundService) GetInboundRemarks(ids []int) (map[int]string, error) { + db := database.GetDB() + remarks := make(map[int]string, len(ids)) + for _, batch := range chunkInts(ids, sqlInChunk) { + var rows []model.Inbound + if err := db.Select("id", "remark").Where("id IN ?", batch).Find(&rows).Error; err != nil { + return nil, err + } + for i := range rows { + remarks[rows[i].Id] = rows[i].Remark + } + } + return remarks, nil +} + func (s *InboundService) GetInboundDetail(id int) (*model.Inbound, error) { db := database.GetDB() inbound := &model.Inbound{} diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go index e11e2ce77..fb8f367ea 100644 --- a/internal/web/service/tgbot/tgbot_client.go +++ b/internal/web/service/tgbot/tgbot_client.go @@ -86,14 +86,10 @@ func (t *Tgbot) describeAttachedInbounds(ids []int) string { if len(ids) == 0 { return "" } + remarks, _ := t.inboundService.GetInboundRemarks(ids) parts := make([]string, 0, len(ids)) for _, id := range ids { - ib, err := t.inboundService.GetInbound(id) - if err != nil || ib == nil { - parts = append(parts, fmt.Sprintf("#%d", id)) - continue - } - label := ib.Remark + label := remarks[id] if label == "" { label = fmt.Sprintf("#%d", id) } diff --git a/internal/web/service/tgbot/tgbot_review_fixes_test.go b/internal/web/service/tgbot/tgbot_review_fixes_test.go index f3c1c1ef3..75dfe3fce 100644 --- a/internal/web/service/tgbot/tgbot_review_fixes_test.go +++ b/internal/web/service/tgbot/tgbot_review_fixes_test.go @@ -2,12 +2,20 @@ package tgbot import ( "encoding/json" + "path/filepath" + "strconv" "strings" "testing" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mymmrac/telego" + "gorm.io/gorm" ) // A login success must not fold into the failure card of the same IP: the @@ -272,3 +280,57 @@ func TestUsageEmailArgumentDoesNotLeakAnotherClient(t *testing.T) { t.Fatalf("a client's /usage leaked another client:\n%s\n%s", body, markup) } } + +// Listing an account's clients used to load their whole inbound, settings and +// all, several times per client, so the list cost its length times the inbound's. +func TestClientUsageDoesNotLoadTheInboundPerClient(t *testing.T) { + sent := recordingBotServer(t) + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + db := database.GetDB() + + const owned = 40 + clients := make([]model.Client, 0, owned) + for i := range owned { + n := strconv.Itoa(i) + clients = append(clients, model.Client{ID: "uuid-" + n, Email: "owned-" + n, SubID: "sub-" + n, TgID: ownerTgID, Enable: true}) + } + settings, err := json.Marshal(map[string]any{"clients": clients}) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + inbound := &model.Inbound{UserId: 1, Tag: "in-443", Remark: "shared-inbound", Enable: true, Port: 443, Protocol: model.VLESS, Settings: string(settings)} + if err := db.Create(inbound).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + if err := (&service.ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + for i := range clients { + if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: clients[i].Email, Enable: true}).Error; err != nil { + t.Fatalf("seed traffic: %v", err) + } + } + + origRunning := isRunning + t.Cleanup(func() { isRunning = origRunning }) + isRunning = true + + wholeRows := 0 + if err := db.Callback().Query().After("gorm:query").Register("test:whole_inbound_rows", func(tx *gorm.DB) { + if tx.Statement.Table == "inbounds" && len(tx.Statement.Selects) == 0 { + wholeRows++ + } + }); err != nil { + t.Fatalf("register query counter: %v", err) + } + t.Cleanup(func() { _ = db.Callback().Query().Remove("test:whole_inbound_rows") }) + + (&Tgbot{}).getClientUsage(ownerTgID, ownerTgID) + + if got := strings.Join(sent(), "\n"); !strings.Contains(got, "shared-inbound") { + t.Fatalf("no client listed with its inbound: %q", got) + } + if wholeRows >= owned { + t.Fatalf("the inbound row was loaded %d times for %d clients", wholeRows, owned) + } +}