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.
This commit is contained in:
MHSanaei
2026-11-01 09:14:43 +01:00
parent 7163c6e521
commit efaa73db3a
4 changed files with 87 additions and 6 deletions
@@ -1351,6 +1351,12 @@ func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, traf
} }
func (s *ClientService) CheckIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) { 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) _, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
if err != nil { if err != nil {
return false, err return false, err
+17
View File
@@ -1802,6 +1802,23 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
return inbound, nil 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) { func (s *InboundService) GetInboundDetail(id int) (*model.Inbound, error) {
db := database.GetDB() db := database.GetDB()
inbound := &model.Inbound{} inbound := &model.Inbound{}
+2 -6
View File
@@ -86,14 +86,10 @@ func (t *Tgbot) describeAttachedInbounds(ids []int) string {
if len(ids) == 0 { if len(ids) == 0 {
return "" return ""
} }
remarks, _ := t.inboundService.GetInboundRemarks(ids)
parts := make([]string, 0, len(ids)) parts := make([]string, 0, len(ids))
for _, id := range ids { for _, id := range ids {
ib, err := t.inboundService.GetInbound(id) label := remarks[id]
if err != nil || ib == nil {
parts = append(parts, fmt.Sprintf("#%d", id))
continue
}
label := ib.Remark
if label == "" { if label == "" {
label = fmt.Sprintf("#%d", id) label = fmt.Sprintf("#%d", id)
} }
@@ -2,12 +2,20 @@ package tgbot
import ( import (
"encoding/json" "encoding/json"
"path/filepath"
"strconv"
"strings" "strings"
"testing" "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/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
"gorm.io/gorm"
) )
// A login success must not fold into the failure card of the same IP: the // 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 <email> leaked another client:\n%s\n%s", body, markup) t.Fatalf("a client's /usage <email> 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)
}
}