mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-06 04:44:21 +00:00
b1fa76f9b6
Deleting a client on the master propagated to nodes via the detach endpoint, which removes the client from that one inbound's settings but deliberately keeps the client record. The node ended up with an orphaned record that kept showing in its Clients view; the master and node could never converge on a delete. Full-delete and detach intent now travel separately: the Runtime interface gains DeleteClient, which on Remote hits the node's panel/api/clients/del endpoint (record, attachments, traffic; repeat calls for a client on several inbounds of the same node are swallowed as idempotent "not found"). Delete/DeleteByEmail/BulkDelete use it for node inbounds, while Detach/BulkDetach keep the inbound-scoped detach RPC so removing a client from one inbound never wipes it node-wide (the #5543 guarantee is preserved and covered by tests). Bulk deletes above the fold threshold still converge membership via reconcile; their leftover node records can be cleaned with the node's delete-orphans action. Closes #5797
392 lines
12 KiB
Go
392 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func syncInboundOld(tx *gorm.DB, inboundId int, clients []model.Client) error {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
|
|
return err
|
|
}
|
|
for i := range clients {
|
|
c := clients[i]
|
|
email := strings.TrimSpace(c.Email)
|
|
if email == "" {
|
|
continue
|
|
}
|
|
incoming := c.ToRecord()
|
|
row := &model.ClientRecord{}
|
|
err := tx.Where("email = ?", email).First(row).Error
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
if err := tx.Create(incoming).Error; err != nil {
|
|
return err
|
|
}
|
|
row = incoming
|
|
} else {
|
|
row.Flow = incoming.Flow
|
|
row.SubID = incoming.SubID
|
|
row.LimitIP = incoming.LimitIP
|
|
row.TotalGB = incoming.TotalGB
|
|
row.ExpiryTime = incoming.ExpiryTime
|
|
row.Enable = incoming.Enable
|
|
row.TgID = incoming.TgID
|
|
row.Comment = incoming.Comment
|
|
row.Reset = incoming.Reset
|
|
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
|
row.UpdatedAt = preservedUpdatedAt
|
|
if err := tx.Save(row).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.ClientRecord{}).
|
|
Where("id = ?", row.Id).
|
|
UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
link := model.ClientInbound{ClientId: row.Id, InboundId: inboundId, FlowOverride: c.Flow}
|
|
if err := tx.Create(&link).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func makeScaleClients(n int) []model.Client {
|
|
out := make([]model.Client, n)
|
|
for i := range n {
|
|
out[i] = model.Client{
|
|
ID: uuid.NewString(),
|
|
Email: fmt.Sprintf("user-%07d@scale", i),
|
|
SubID: fmt.Sprintf("sub-%07d", i),
|
|
Enable: true,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestSyncInboundPostgresScale(t *testing.T) {
|
|
setupScaleDB(t)
|
|
|
|
svc := &ClientService{}
|
|
sizes := []int{5000, 10000, 20000, 50000, 100000, 200000}
|
|
|
|
for _, n := range sizes {
|
|
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
|
db := database.GetDB()
|
|
resetScaleTables(t, db, "inbounds", "clients", "client_inbounds")
|
|
|
|
clients := makeScaleClients(n)
|
|
ib := &model.Inbound{
|
|
Tag: fmt.Sprintf("scale-%d", n),
|
|
Enable: true,
|
|
Port: 40000,
|
|
Protocol: model.VLESS,
|
|
Settings: clientsSettings(t, clients),
|
|
}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
|
|
start := time.Now()
|
|
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
|
t.Fatalf("seed SyncInbound: %v", err)
|
|
}
|
|
seed := time.Since(start)
|
|
|
|
clients[n/2].Enable = !clients[n/2].Enable
|
|
start = time.Now()
|
|
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
|
t.Fatalf("toggle SyncInbound (new): %v", err)
|
|
}
|
|
toggleNew := time.Since(start)
|
|
|
|
start = time.Now()
|
|
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
|
t.Fatalf("noop SyncInbound (new): %v", err)
|
|
}
|
|
noopNew := time.Since(start)
|
|
|
|
toggleOld := time.Duration(0)
|
|
if n <= 10000 {
|
|
clients[n/2].Enable = !clients[n/2].Enable
|
|
start = time.Now()
|
|
if err := syncInboundOld(db, ib.Id, clients); err != nil {
|
|
t.Fatalf("toggle SyncInbound (old): %v", err)
|
|
}
|
|
toggleOld = time.Since(start)
|
|
}
|
|
|
|
var linkCount, recCount int64
|
|
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
|
db.Model(&model.ClientRecord{}).Count(&recCount)
|
|
if int(linkCount) != n || int(recCount) != n {
|
|
t.Fatalf("row mismatch: links=%d records=%d want %d", linkCount, recCount, n)
|
|
}
|
|
|
|
oldStr, speedup := "skipped", ""
|
|
if toggleOld > 0 {
|
|
oldStr = toggleOld.Round(time.Millisecond).String()
|
|
speedup = fmt.Sprintf(" speedup=%.0fx", float64(toggleOld)/float64(maxDur(toggleNew, time.Millisecond)))
|
|
}
|
|
t.Logf("N=%-7d seed=%-10v toggle_new=%-10v noop_new=%-10v toggle_old=%-10s%s",
|
|
n, seed.Round(time.Millisecond), toggleNew.Round(time.Millisecond),
|
|
noopNew.Round(time.Millisecond), oldStr, speedup)
|
|
})
|
|
}
|
|
}
|
|
|
|
func maxDur(d, floor time.Duration) time.Duration {
|
|
if d < floor {
|
|
return floor
|
|
}
|
|
return d
|
|
}
|
|
|
|
func TestAddDelClientPostgresScale(t *testing.T) {
|
|
setupScaleDB(t)
|
|
|
|
svc := &ClientService{}
|
|
inboundSvc := &InboundService{}
|
|
sizes := []int{5000, 20000, 50000, 100000, 200000}
|
|
|
|
for _, n := range sizes {
|
|
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
|
db := database.GetDB()
|
|
resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
|
|
|
|
clients := makeScaleClients(n)
|
|
ib := &model.Inbound{
|
|
Tag: fmt.Sprintf("adddel-%d", n),
|
|
Enable: true,
|
|
Port: 40000,
|
|
Protocol: model.VLESS,
|
|
Settings: clientsSettings(t, clients),
|
|
}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
|
t.Fatalf("seed SyncInbound: %v", err)
|
|
}
|
|
|
|
newC := model.Client{
|
|
ID: uuid.NewString(),
|
|
Email: "added-client@scale",
|
|
SubID: "added-sub",
|
|
Enable: true,
|
|
}
|
|
addData := &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{newC})}
|
|
start := time.Now()
|
|
if _, err := svc.AddInboundClient(inboundSvc, addData); err != nil {
|
|
t.Fatalf("AddInboundClient: %v", err)
|
|
}
|
|
addDur := time.Since(start)
|
|
|
|
delEmail := clients[n/2].Email
|
|
start = time.Now()
|
|
if _, err := svc.DelInboundClientByEmail(inboundSvc, ib.Id, delEmail, false, false); err != nil {
|
|
t.Fatalf("DelInboundClientByEmail: %v", err)
|
|
}
|
|
delDur := time.Since(start)
|
|
|
|
var recCount, linkCount int64
|
|
db.Model(&model.ClientRecord{}).Count(&recCount)
|
|
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
|
|
|
t.Logf("N=%-7d add=%-10v del=%-10v records=%d links=%d", n,
|
|
addDur.Round(time.Millisecond), delDur.Round(time.Millisecond), recCount, linkCount)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGroupAndListPostgresScale(t *testing.T) {
|
|
setupScaleDB(t)
|
|
|
|
svc := &ClientService{}
|
|
sizes := []int{5000, 100000}
|
|
|
|
for _, n := range sizes {
|
|
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
|
db := database.GetDB()
|
|
resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
|
|
clients := makeScaleClients(n)
|
|
ib := &model.Inbound{Tag: fmt.Sprintf("grp-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
|
t.Fatalf("seed SyncInbound: %v", err)
|
|
}
|
|
db.Exec("ANALYZE")
|
|
emails := make([]string, n)
|
|
for i := range n {
|
|
emails[i] = clients[i].Email
|
|
}
|
|
|
|
start := time.Now()
|
|
if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil {
|
|
t.Fatalf("AddToGroup: %v", err)
|
|
}
|
|
addDur := time.Since(start)
|
|
|
|
start = time.Now()
|
|
if _, err := svc.RemoveFromGroup(emails); err != nil {
|
|
t.Fatalf("RemoveFromGroup: %v", err)
|
|
}
|
|
rmDur := time.Since(start)
|
|
|
|
start = time.Now()
|
|
list, err := svc.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
listDur := time.Since(start)
|
|
if len(list) != n {
|
|
t.Fatalf("List returned %d, want %d", len(list), n)
|
|
}
|
|
|
|
t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n,
|
|
addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDelAllClientsPostgresScale(t *testing.T) {
|
|
setupScaleDB(t)
|
|
|
|
svc := &ClientService{}
|
|
inboundSvc := &InboundService{}
|
|
sizes := []int{5000, 50000, 100000}
|
|
|
|
for _, n := range sizes {
|
|
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
|
db := database.GetDB()
|
|
resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
|
|
clients := makeScaleClients(n)
|
|
ib := &model.Inbound{Tag: fmt.Sprintf("delall-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
|
t.Fatalf("seed SyncInbound: %v", err)
|
|
}
|
|
|
|
emails, err := inboundSvc.EmailsByInbound(ib.Id)
|
|
if err != nil {
|
|
t.Fatalf("EmailsByInbound: %v", err)
|
|
}
|
|
start := time.Now()
|
|
res, _, err := svc.BulkDelete(inboundSvc, emails, false)
|
|
if err != nil {
|
|
t.Fatalf("BulkDelete: %v", err)
|
|
}
|
|
dur := time.Since(start)
|
|
|
|
var recCount, linkCount int64
|
|
db.Model(&model.ClientRecord{}).Count(&recCount)
|
|
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
|
if recCount != 0 || linkCount != 0 {
|
|
t.Fatalf("after delAll: records=%d links=%d want 0/0", recCount, linkCount)
|
|
}
|
|
t.Logf("N=%-7d delAllClients=%-10v deleted=%d", n, dur.Round(time.Millisecond), res.Deleted)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBulkOpsPostgresScale(t *testing.T) {
|
|
setupScaleDB(t)
|
|
|
|
svc := &ClientService{}
|
|
inboundSvc := &InboundService{}
|
|
sizes := []int{5000, 20000, 50000, 100000}
|
|
const m = 2000
|
|
|
|
for _, n := range sizes {
|
|
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
|
db := database.GetDB()
|
|
resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
|
|
|
|
clients := makeScaleClients(n)
|
|
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
|
|
for i := range clients {
|
|
clients[i].ExpiryTime = exp
|
|
clients[i].TotalGB = 100 << 30
|
|
}
|
|
ib := &model.Inbound{Tag: fmt.Sprintf("bulk-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
|
t.Fatalf("seed SyncInbound: %v", err)
|
|
}
|
|
ib2 := &model.Inbound{Tag: fmt.Sprintf("bulk2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
|
if err := db.Create(ib2).Error; err != nil {
|
|
t.Fatalf("create inbound2: %v", err)
|
|
}
|
|
|
|
emailsM := make([]string, m)
|
|
for i := range m {
|
|
emailsM[i] = clients[i].Email
|
|
}
|
|
|
|
t0 := time.Now()
|
|
if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30, ""); err != nil {
|
|
t.Fatalf("BulkAdjust: %v", err)
|
|
}
|
|
adjustDur := time.Since(t0)
|
|
|
|
t0 = time.Now()
|
|
if _, _, err := svc.BulkAttach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
|
|
t.Fatalf("BulkAttach: %v", err)
|
|
}
|
|
attachDur := time.Since(t0)
|
|
|
|
t0 = time.Now()
|
|
if _, _, err := svc.BulkDetach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
|
|
t.Fatalf("BulkDetach: %v", err)
|
|
}
|
|
detachDur := time.Since(t0)
|
|
|
|
payloads := make([]ClientCreatePayload, m)
|
|
for i := range m {
|
|
payloads[i] = ClientCreatePayload{
|
|
Client: model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("bulknew-%07d@scale", i), SubID: fmt.Sprintf("bnsub-%07d", i), Enable: true},
|
|
InboundIds: []int{ib.Id},
|
|
}
|
|
}
|
|
t0 = time.Now()
|
|
if _, _, err := svc.BulkCreate(inboundSvc, payloads); err != nil {
|
|
t.Fatalf("BulkCreate: %v", err)
|
|
}
|
|
createDur := time.Since(t0)
|
|
|
|
t0 = time.Now()
|
|
if _, _, err := svc.BulkDelete(inboundSvc, emailsM, false); err != nil {
|
|
t.Fatalf("BulkDelete: %v", err)
|
|
}
|
|
deleteDur := time.Since(t0)
|
|
|
|
t.Logf("N=%-6d M=%d adjust=%-9v attach=%-9v detach=%-9v create=%-9v delete=%-9v", n, m,
|
|
adjustDur.Round(time.Millisecond), attachDur.Round(time.Millisecond), detachDur.Round(time.Millisecond),
|
|
createDur.Round(time.Millisecond), deleteDur.Round(time.Millisecond))
|
|
})
|
|
}
|
|
}
|