mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-13 23:01:00 +00:00
test(scale): cover traffic poll, ws payloads, ip-limit job, sub and xray config at 500k
The paths that run continuously in production had no scale coverage: the 5s traffic poll (AddTraffic with its auto-renew and depleted scans), the websocket snapshot the job broadcasts while a browser is connected, the 10s ip-limit job (hasLimitIp LIKE scan + per-email settings parse), a subscription fetch inside a huge inbound, and the full Xray config build. New benchmarks reuse the XUI_SCALE_TEST / XUI_DB_TYPE gating and stay log-only. Sizes default to 10k/100k; XUI_SCALE_SIZES=500000 raises the ladder without editing code. seedScaleDataset writes inbounds, clients, client_inbounds and client_traffics directly in one transaction instead of SyncInbound, so a 500k seed takes seconds. XUI_SCALE_DB_PATH persists the seeded SQLite file for manual smoke runs against a live panel.
This commit is contained in:
@@ -0,0 +1,241 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
const scaleTargetSubId = "scale-target-sub"
|
||||||
|
|
||||||
|
// setupScaleSubDB mirrors the service package's scale gating: Postgres via
|
||||||
|
// XUI_DB_TYPE/XUI_DB_DSN, SQLite via XUI_SCALE_TEST=1, skip otherwise.
|
||||||
|
func setupScaleSubDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
xuilogger.InitLogger(logging.ERROR)
|
||||||
|
|
||||||
|
if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
|
||||||
|
if err := database.InitDB(""); err != nil {
|
||||||
|
t.Fatalf("InitDB(postgres): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
||||||
|
case "1", "true", "yes":
|
||||||
|
if err := database.InitDB(filepath.Join(t.TempDir(), "scale.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB(sqlite): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaleSubSizes(t *testing.T, def ...int) []int {
|
||||||
|
t.Helper()
|
||||||
|
raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
|
||||||
|
if raw == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
var out []int
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(part)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetScaleSubTables(t *testing.T, db *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
if config.GetDBKind() == "postgres" {
|
||||||
|
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||||
|
t.Fatalf("truncate: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, tbl := range []string{"inbounds", "clients", "client_inbounds", "client_traffics"} {
|
||||||
|
if err := db.Exec("DELETE FROM " + tbl).Error; err != nil {
|
||||||
|
t.Fatalf("delete %s: %v", tbl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Exec("DELETE FROM sqlite_sequence")
|
||||||
|
}
|
||||||
|
if err := db.Where("1 = 1").Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||||
|
t.Fatalf("clear client_external_links: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedScaleSubDataset seeds one VLESS inbound holding n clients (the sub
|
||||||
|
// server's worst case: matchingClients parses the whole settings blob and
|
||||||
|
// getInboundsBySubId preloads every ClientStats row). Three clients share
|
||||||
|
// scaleTargetSubId; everyone else gets a unique subId.
|
||||||
|
func seedScaleSubDataset(t *testing.T, n int) {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
resetScaleSubTables(t, db)
|
||||||
|
|
||||||
|
clients := make([]model.Client, n)
|
||||||
|
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
|
||||||
|
targets := map[int]bool{n / 4: true, n / 2: true, 3 * n / 4: true}
|
||||||
|
for i := range n {
|
||||||
|
subId := fmt.Sprintf("sub-%07d", i)
|
||||||
|
if targets[i] {
|
||||||
|
subId = scaleTargetSubId
|
||||||
|
}
|
||||||
|
clients[i] = model.Client{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Email: fmt.Sprintf("user-%07d@subscale", i),
|
||||||
|
SubID: subId,
|
||||||
|
Enable: true,
|
||||||
|
ExpiryTime: exp,
|
||||||
|
TotalGB: 100 << 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsMap := map[string]any{"clients": clients, "decryption": "none"}
|
||||||
|
settings, err := json.Marshal(settingsMap)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
t.Fatalf("begin seed tx: %v", tx.Error)
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ib := &model.Inbound{
|
||||||
|
UserId: 1,
|
||||||
|
Tag: fmt.Sprintf("subscale-%d", n),
|
||||||
|
Remark: "subscale",
|
||||||
|
Enable: true,
|
||||||
|
Listen: "203.0.113.1",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: string(settings),
|
||||||
|
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||||
|
}
|
||||||
|
if err := tx.Create(ib).Error; err != nil {
|
||||||
|
t.Fatalf("seed inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]*model.ClientRecord, n)
|
||||||
|
for i := range clients {
|
||||||
|
records[i] = clients[i].ToRecord()
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(records, 500).Error; err != nil {
|
||||||
|
t.Fatalf("seed clients: %v", err)
|
||||||
|
}
|
||||||
|
links := make([]model.ClientInbound, n)
|
||||||
|
for i := range records {
|
||||||
|
links[i] = model.ClientInbound{ClientId: records[i].Id, InboundId: ib.Id}
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(links, 1000).Error; err != nil {
|
||||||
|
t.Fatalf("seed client_inbounds: %v", err)
|
||||||
|
}
|
||||||
|
traffics := make([]xray.ClientTraffic, n)
|
||||||
|
for i := range clients {
|
||||||
|
traffics[i] = xray.ClientTraffic{
|
||||||
|
InboundId: ib.Id,
|
||||||
|
Email: clients[i].Email,
|
||||||
|
Enable: true,
|
||||||
|
Total: clients[i].TotalGB,
|
||||||
|
ExpiryTime: clients[i].ExpiryTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(traffics, 1000).Error; err != nil {
|
||||||
|
t.Fatalf("seed client_traffics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
t.Fatalf("commit seed tx: %v", err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
db.Exec("ANALYZE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetSubsScale measures one subscription fetch (raw and JSON format) for a
|
||||||
|
// 3-client subId living inside an n-client inbound, plus a subId miss — the
|
||||||
|
// per-request cost every subscriber pays.
|
||||||
|
func TestGetSubsScale(t *testing.T) {
|
||||||
|
for _, n := range scaleSubSizes(t, 10000, 100000) {
|
||||||
|
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||||
|
setupScaleSubDB(t)
|
||||||
|
seedScaleSubDataset(t, n)
|
||||||
|
|
||||||
|
svc := &SubService{}
|
||||||
|
const reps = 5
|
||||||
|
start := time.Now()
|
||||||
|
var links []string
|
||||||
|
for range reps {
|
||||||
|
var err error
|
||||||
|
links, _, _, _, err = svc.GetSubs(scaleTargetSubId, "sub.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubs: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rawDur := time.Since(start) / reps
|
||||||
|
if len(links) != 3 {
|
||||||
|
t.Fatalf("GetSubs links = %d, want 3", len(links))
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSvc := NewSubJsonService("", "", "", &SubService{})
|
||||||
|
start = time.Now()
|
||||||
|
for range reps {
|
||||||
|
body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetJson: %v", err)
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
t.Fatal("GetJson returned empty body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonDur := time.Since(start) / reps
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
for range reps {
|
||||||
|
missLinks, _, _, _, err := svc.GetSubs("no-such-sub", "sub.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubs miss: %v", err)
|
||||||
|
}
|
||||||
|
if len(missLinks) != 0 {
|
||||||
|
t.Fatalf("GetSubs miss links = %d, want 0", len(missLinks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missDur := time.Since(start) / reps
|
||||||
|
|
||||||
|
t.Logf("N=%-7d raw=%-10v json=%-10v miss=%v",
|
||||||
|
n, rawDur.Round(time.Millisecond), jsonDur.Round(time.Millisecond), missDur.Round(time.Millisecond))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupScaleJobDB mirrors the service package's scale gating: Postgres via
|
||||||
|
// XUI_DB_TYPE/XUI_DB_DSN, SQLite via XUI_SCALE_TEST=1, skip otherwise.
|
||||||
|
func setupScaleJobDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
loggerInitOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
|
||||||
|
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
||||||
|
|
||||||
|
if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
|
||||||
|
if err := database.InitDB(""); err != nil {
|
||||||
|
t.Fatalf("InitDB(postgres): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
||||||
|
case "1", "true", "yes":
|
||||||
|
if err := database.InitDB(filepath.Join(t.TempDir(), "scale.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB(sqlite): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaleJobSizes(t *testing.T, def ...int) []int {
|
||||||
|
t.Helper()
|
||||||
|
raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
|
||||||
|
if raw == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
var out []int
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(part)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetScaleJobTables(t *testing.T, db *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
if config.GetDBKind() == "postgres" {
|
||||||
|
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
|
||||||
|
t.Fatalf("truncate: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, tbl := range []string{"inbounds", "clients", "client_inbounds"} {
|
||||||
|
if err := db.Exec("DELETE FROM " + tbl).Error; err != nil {
|
||||||
|
t.Fatalf("delete %s: %v", tbl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Exec("DELETE FROM sqlite_sequence")
|
||||||
|
}
|
||||||
|
if err := db.Where("1 = 1").Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||||
|
t.Fatalf("clear inbound client ips: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Where("1 = 1").Delete(&model.NodeClientIp{}).Error; err != nil {
|
||||||
|
t.Fatalf("clear node client ips: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedScaleIPDataset seeds n clients across numInbounds inbounds. Every client
|
||||||
|
// in the LAST inbound carries limitIp=3 (and 0 elsewhere), so hasLimitIp pays
|
||||||
|
// its full scan cost before finding a hit, and the returned emails all resolve
|
||||||
|
// to that last inbound for the processObserved measurement.
|
||||||
|
func seedScaleIPDataset(t *testing.T, n, numInbounds int) []string {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
resetScaleJobTables(t, db)
|
||||||
|
|
||||||
|
tx := db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
t.Fatalf("begin seed tx: %v", tx.Error)
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var limitedEmails []string
|
||||||
|
per := n / numInbounds
|
||||||
|
for i := range numInbounds {
|
||||||
|
lo, hi := i*per, (i+1)*per
|
||||||
|
if i == numInbounds-1 {
|
||||||
|
hi = n
|
||||||
|
}
|
||||||
|
limitIp := 0
|
||||||
|
if i == numInbounds-1 {
|
||||||
|
limitIp = 3
|
||||||
|
}
|
||||||
|
clients := make([]model.Client, 0, hi-lo)
|
||||||
|
records := make([]*model.ClientRecord, 0, hi-lo)
|
||||||
|
for j := lo; j < hi; j++ {
|
||||||
|
email := fmt.Sprintf("user-%07d@ipscale", j)
|
||||||
|
clients = append(clients, model.Client{Email: email, LimitIP: limitIp, Enable: true})
|
||||||
|
records = append(records, &model.ClientRecord{Email: email, LimitIP: limitIp, Enable: true})
|
||||||
|
if limitIp > 0 {
|
||||||
|
limitedEmails = append(limitedEmails, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings, err := json.Marshal(map[string][]model.Client{"clients": clients})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal settings: %v", err)
|
||||||
|
}
|
||||||
|
ib := &model.Inbound{
|
||||||
|
UserId: 1,
|
||||||
|
Tag: fmt.Sprintf("ipscale-%d-%d", n, i),
|
||||||
|
Enable: true,
|
||||||
|
Port: 42000 + i,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: string(settings),
|
||||||
|
}
|
||||||
|
if err := tx.Create(ib).Error; err != nil {
|
||||||
|
t.Fatalf("seed inbound %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(records, 500).Error; err != nil {
|
||||||
|
t.Fatalf("seed clients %d: %v", i, err)
|
||||||
|
}
|
||||||
|
links := make([]model.ClientInbound, len(records))
|
||||||
|
for j := range records {
|
||||||
|
links[j] = model.ClientInbound{ClientId: records[j].Id, InboundId: ib.Id}
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(links, 1000).Error; err != nil {
|
||||||
|
t.Fatalf("seed client_inbounds %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
t.Fatalf("commit seed tx: %v", err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
db.Exec("ANALYZE")
|
||||||
|
return limitedEmails
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckClientIpScale measures the @every 10s ip-limit job pieces: the
|
||||||
|
// hasLimitIp gate (settings LIKE scan + full JSON parse of every matching
|
||||||
|
// inbound) and processObserved with M online users (per-email inbound lookup,
|
||||||
|
// settings parse and autocommit save). Run twice: first scan half add / half
|
||||||
|
// update, second scan all update path.
|
||||||
|
func TestCheckClientIpScale(t *testing.T) {
|
||||||
|
shapes := []struct {
|
||||||
|
name string
|
||||||
|
inbounds int
|
||||||
|
observed int
|
||||||
|
}{{"single", 1, 50}, {"spread50", 50, 1000}}
|
||||||
|
|
||||||
|
for _, n := range scaleJobSizes(t, 10000, 100000) {
|
||||||
|
for _, shape := range shapes {
|
||||||
|
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
|
||||||
|
setupScaleJobDB(t)
|
||||||
|
limited := seedScaleIPDataset(t, n, shape.inbounds)
|
||||||
|
m := min(shape.observed, len(limited))
|
||||||
|
|
||||||
|
j := NewCheckClientIpJob()
|
||||||
|
const reps = 3
|
||||||
|
start := time.Now()
|
||||||
|
for range reps {
|
||||||
|
if !j.hasLimitIp() {
|
||||||
|
t.Fatal("hasLimitIp = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("N=%-7d shape=%-8s hasLimitIp=%v/call", n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
observed := make(map[string]map[string]int64, m)
|
||||||
|
for i := range m {
|
||||||
|
observed[limited[i]] = map[string]int64{
|
||||||
|
fmt.Sprintf("10.0.%d.%d", i/250, i%250+1): now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range m / 2 {
|
||||||
|
seedClientIps(t, limited[i], []IPWithTimestamp{{IP: "10.99.0.1", Timestamp: now - 60}})
|
||||||
|
}
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
j.processObserved(observed, true, true)
|
||||||
|
firstScan := time.Since(start)
|
||||||
|
start = time.Now()
|
||||||
|
j.processObserved(observed, true, true)
|
||||||
|
secondScan := time.Since(start)
|
||||||
|
t.Logf("N=%-7d shape=%-8s processObserved M=%-5d first=%-10v second=%-10v (%.1fms/email)",
|
||||||
|
n, shape.name, m, firstScan.Round(time.Millisecond), secondScan.Round(time.Millisecond),
|
||||||
|
float64(secondScan.Milliseconds())/float64(m))
|
||||||
|
|
||||||
|
var rows int64
|
||||||
|
if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("count ip rows: %v", err)
|
||||||
|
}
|
||||||
|
if rows != int64(m) {
|
||||||
|
t.Fatalf("inbound_client_ips rows = %d, want %d", rows, m)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,28 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
"github.com/op/go-logging"
|
"github.com/op/go-logging"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupScaleDB initializes the DB for a scale benchmark on either Postgres
|
// setupScaleDB initializes the DB for a scale benchmark on either Postgres
|
||||||
// (XUI_DB_TYPE=postgres + XUI_DB_DSN) or SQLite (XUI_SCALE_TEST=1, temp file),
|
// (XUI_DB_TYPE=postgres + XUI_DB_DSN) or SQLite (XUI_SCALE_TEST=1, temp file;
|
||||||
// and registers cleanup. Skips the test when neither backend is configured.
|
// XUI_SCALE_DB_PATH persists the DB for manual smoke runs), and registers
|
||||||
|
// cleanup. Skips the test when neither backend is configured.
|
||||||
func setupScaleDB(t *testing.T) {
|
func setupScaleDB(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
xuilogger.InitLogger(logging.ERROR)
|
xuilogger.InitLogger(logging.ERROR)
|
||||||
@@ -31,7 +37,10 @@ func setupScaleDB(t *testing.T) {
|
|||||||
|
|
||||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
||||||
case "1", "true", "yes":
|
case "1", "true", "yes":
|
||||||
dbPath := filepath.Join(t.TempDir(), "scale.db")
|
dbPath := strings.TrimSpace(os.Getenv("XUI_SCALE_DB_PATH"))
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = filepath.Join(t.TempDir(), "scale.db")
|
||||||
|
}
|
||||||
if err := database.InitDB(dbPath); err != nil {
|
if err := database.InitDB(dbPath); err != nil {
|
||||||
t.Fatalf("InitDB(sqlite): %v", err)
|
t.Fatalf("InitDB(sqlite): %v", err)
|
||||||
}
|
}
|
||||||
@@ -42,6 +51,144 @@ func setupScaleDB(t *testing.T) {
|
|||||||
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
|
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scaleSizes returns the default size ladder unless XUI_SCALE_SIZES overrides
|
||||||
|
// it with a comma-separated list (e.g. "500000" or "10000,100000,500000").
|
||||||
|
func scaleSizes(t *testing.T, def ...int) []int {
|
||||||
|
t.Helper()
|
||||||
|
raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
|
||||||
|
if raw == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
var out []int
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(part)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type scaleDataset struct {
|
||||||
|
inboundIds []int
|
||||||
|
tags []string
|
||||||
|
emails []string
|
||||||
|
perInbound [][]model.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedScaleDataset seeds n healthy clients (future expiry, unfilled quota)
|
||||||
|
// spread across numInbounds inbounds, writing inbounds, clients,
|
||||||
|
// client_inbounds and client_traffics directly in one transaction — orders of
|
||||||
|
// magnitude faster than SyncInbound and one fsync instead of thousands.
|
||||||
|
func seedScaleDataset(t *testing.T, n, numInbounds int) scaleDataset {
|
||||||
|
t.Helper()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
ds := scaleDataset{emails: emailsOf(clients)}
|
||||||
|
start := time.Now()
|
||||||
|
tx := db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
t.Fatalf("begin seed tx: %v", tx.Error)
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
per := n / numInbounds
|
||||||
|
for i := range numInbounds {
|
||||||
|
lo, hi := i*per, (i+1)*per
|
||||||
|
if i == numInbounds-1 {
|
||||||
|
hi = n
|
||||||
|
}
|
||||||
|
chunk := clients[lo:hi]
|
||||||
|
ib := &model.Inbound{
|
||||||
|
UserId: 1,
|
||||||
|
Tag: fmt.Sprintf("scale-%d-%d", n, i),
|
||||||
|
Enable: true,
|
||||||
|
Port: 41000 + i,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: clientsSettings(t, chunk),
|
||||||
|
}
|
||||||
|
if err := tx.Create(ib).Error; err != nil {
|
||||||
|
t.Fatalf("seed inbound %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]*model.ClientRecord, len(chunk))
|
||||||
|
for j := range chunk {
|
||||||
|
records[j] = chunk[j].ToRecord()
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(records, 500).Error; err != nil {
|
||||||
|
t.Fatalf("seed clients %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
links := make([]model.ClientInbound, len(records))
|
||||||
|
for j := range records {
|
||||||
|
links[j] = model.ClientInbound{ClientId: records[j].Id, InboundId: ib.Id}
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(links, 1000).Error; err != nil {
|
||||||
|
t.Fatalf("seed client_inbounds %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
traffics := make([]xray.ClientTraffic, len(chunk))
|
||||||
|
for j := range chunk {
|
||||||
|
traffics[j] = xray.ClientTraffic{
|
||||||
|
InboundId: ib.Id,
|
||||||
|
Email: chunk[j].Email,
|
||||||
|
Enable: true,
|
||||||
|
Total: chunk[j].TotalGB,
|
||||||
|
ExpiryTime: chunk[j].ExpiryTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(traffics, 1000).Error; err != nil {
|
||||||
|
t.Fatalf("seed client_traffics %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ds.inboundIds = append(ds.inboundIds, ib.Id)
|
||||||
|
ds.tags = append(ds.tags, ib.Tag)
|
||||||
|
ds.perInbound = append(ds.perInbound, chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
t.Fatalf("commit seed tx: %v", err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
db.Exec("ANALYZE")
|
||||||
|
t.Logf("seeded N=%d across %d inbound(s) in %v", n, numInbounds, time.Since(start).Round(time.Millisecond))
|
||||||
|
return ds
|
||||||
|
}
|
||||||
|
|
||||||
|
// sampleEmails picks k evenly spaced emails so active clients span the id range.
|
||||||
|
func sampleEmails(emails []string, k int) []string {
|
||||||
|
if k >= len(emails) {
|
||||||
|
return emails
|
||||||
|
}
|
||||||
|
out := make([]string, 0, k)
|
||||||
|
step := len(emails) / k
|
||||||
|
for i := 0; i < len(emails) && len(out) < k; i += step {
|
||||||
|
out = append(out, emails[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// resetScaleTables empties the given tables between sub-sizes. Postgres uses a
|
// resetScaleTables empties the given tables between sub-sizes. Postgres uses a
|
||||||
// single TRUNCATE ... CASCADE; SQLite deletes per table and clears the
|
// single TRUNCATE ... CASCADE; SQLite deletes per table and clears the
|
||||||
// autoincrement counters so ids restart like RESTART IDENTITY.
|
// autoincrement counters so ids restart like RESTART IDENTITY.
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pollReport(ds scaleDataset, k int) ([]*xray.Traffic, []*xray.ClientTraffic) {
|
||||||
|
traffics := make([]*xray.Traffic, 0, len(ds.tags))
|
||||||
|
for _, tag := range ds.tags {
|
||||||
|
traffics = append(traffics, &xray.Traffic{IsInbound: true, Tag: tag, Up: 1 << 20, Down: 2 << 20})
|
||||||
|
}
|
||||||
|
clientTraffics := make([]*xray.ClientTraffic, 0, k)
|
||||||
|
for _, email := range sampleEmails(ds.emails, k) {
|
||||||
|
clientTraffics = append(clientTraffics, &xray.ClientTraffic{Email: email, Up: 1 << 20, Down: 2 << 20})
|
||||||
|
}
|
||||||
|
return traffics, clientTraffics
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddTrafficPollScale measures one full traffic-poll cycle (the @every 5s
|
||||||
|
// job): per-client delta UPDATEs, the auto-renew probe and the depleted-client
|
||||||
|
// scan, in steady state and with 100 clients depleting / renewing.
|
||||||
|
func TestAddTrafficPollScale(t *testing.T) {
|
||||||
|
setupScaleDB(t)
|
||||||
|
svc := &InboundService{}
|
||||||
|
shapes := []struct {
|
||||||
|
name string
|
||||||
|
inbounds int
|
||||||
|
}{{"single", 1}, {"spread50", 50}}
|
||||||
|
|
||||||
|
for _, n := range scaleSizes(t, 10000, 100000) {
|
||||||
|
for _, shape := range shapes {
|
||||||
|
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
|
||||||
|
db := database.GetDB()
|
||||||
|
ds := seedScaleDataset(t, n, shape.inbounds)
|
||||||
|
|
||||||
|
for _, k := range []int{1000, 10000} {
|
||||||
|
if k > n {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
traffics, clientTraffics := pollReport(ds, k)
|
||||||
|
const reps = 3
|
||||||
|
start := time.Now()
|
||||||
|
for range reps {
|
||||||
|
if _, _, err := svc.AddTraffic(traffics, clientTraffics); err != nil {
|
||||||
|
t.Fatalf("AddTraffic steady: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
perPoll := time.Since(start) / reps
|
||||||
|
t.Logf("N=%-7d shape=%-8s K=%-6d steady=%v/poll", n, shape.name, k, perPoll.Round(time.Millisecond))
|
||||||
|
|
||||||
|
var probe xray.ClientTraffic
|
||||||
|
if err := db.Where("email = ?", clientTraffics[0].Email).First(&probe).Error; err != nil {
|
||||||
|
t.Fatalf("load probe row: %v", err)
|
||||||
|
}
|
||||||
|
if probe.Up == 0 || probe.Down == 0 {
|
||||||
|
t.Fatalf("steady polls did not accumulate traffic: up=%d down=%d", probe.Up, probe.Down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
depleted := ds.perInbound[0][:100]
|
||||||
|
if err := db.Model(&xray.ClientTraffic{}).
|
||||||
|
Where("email IN ?", emailsOf(depleted)).
|
||||||
|
Updates(map[string]any{"up": int64(100 << 30), "down": int64(0)}).Error; err != nil {
|
||||||
|
t.Fatalf("mark depleted: %v", err)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
if _, _, err := svc.AddTraffic(nil, nil); err != nil {
|
||||||
|
t.Fatalf("AddTraffic disable: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("N=%-7d shape=%-8s disable100=%v", n, shape.name, time.Since(start).Round(time.Millisecond))
|
||||||
|
var disabledCount int64
|
||||||
|
if err := db.Model(&xray.ClientTraffic{}).Where("enable = ?", false).Count(&disabledCount).Error; err != nil {
|
||||||
|
t.Fatalf("count disabled: %v", err)
|
||||||
|
}
|
||||||
|
if disabledCount != 100 {
|
||||||
|
t.Fatalf("disable100: got %d disabled rows, want 100", disabledCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
renew := ds.perInbound[0][100:200]
|
||||||
|
past := time.Now().Add(-time.Hour).UnixMilli()
|
||||||
|
if err := db.Model(&xray.ClientTraffic{}).
|
||||||
|
Where("email IN ?", emailsOf(renew)).
|
||||||
|
Updates(map[string]any{"reset": 30, "expiry_time": past, "up": int64(1 << 30)}).Error; err != nil {
|
||||||
|
t.Fatalf("mark renewable: %v", err)
|
||||||
|
}
|
||||||
|
start = time.Now()
|
||||||
|
if _, _, err := svc.AddTraffic(nil, nil); err != nil {
|
||||||
|
t.Fatalf("AddTraffic renew: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("N=%-7d shape=%-8s renew100=%v", n, shape.name, time.Since(start).Round(time.Millisecond))
|
||||||
|
var renewed xray.ClientTraffic
|
||||||
|
if err := db.Where("email = ?", renew[0].Email).First(&renewed).Error; err != nil {
|
||||||
|
t.Fatalf("load renewed row: %v", err)
|
||||||
|
}
|
||||||
|
if renewed.ExpiryTime <= past || renewed.Up != 0 {
|
||||||
|
t.Fatalf("renew100 did not renew: expiry=%d up=%d", renewed.ExpiryTime, renewed.Up)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWsPayloadScale compares the websocket broadcast payload paths the
|
||||||
|
// traffic job runs every 5s while a browser is connected: the full snapshot
|
||||||
|
// (GetAllClientTraffics + full last-online map) against the delta variant
|
||||||
|
// (GetActiveClientTraffics on this poll's active emails). Payload sizes are
|
||||||
|
// logged against the hub's 10MB drop threshold.
|
||||||
|
func TestWsPayloadScale(t *testing.T) {
|
||||||
|
setupScaleDB(t)
|
||||||
|
svc := &InboundService{}
|
||||||
|
const hubPayloadLimit = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
for _, n := range scaleSizes(t, 10000, 100000) {
|
||||||
|
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||||
|
ds := seedScaleDataset(t, n, 1)
|
||||||
|
k := min(10000, n)
|
||||||
|
activeEmails := sampleEmails(ds.emails, k)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
all, err := svc.GetAllClientTraffics()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAllClientTraffics: %v", err)
|
||||||
|
}
|
||||||
|
fetchAll := time.Since(start)
|
||||||
|
if len(all) != n {
|
||||||
|
t.Fatalf("GetAllClientTraffics rows = %d, want %d", len(all), n)
|
||||||
|
}
|
||||||
|
start = time.Now()
|
||||||
|
snapshot, err := json.Marshal(map[string]any{"clients": all})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal snapshot: %v", err)
|
||||||
|
}
|
||||||
|
marshalAll := time.Since(start)
|
||||||
|
verdict := "delivered"
|
||||||
|
if len(snapshot) > hubPayloadLimit {
|
||||||
|
verdict = "DROPPED by hub (>10MB)"
|
||||||
|
}
|
||||||
|
t.Logf("N=%-7d snapshot: fetch=%-9v marshal=%-9v payload=%.1fMB %s",
|
||||||
|
n, fetchAll.Round(time.Millisecond), marshalAll.Round(time.Millisecond),
|
||||||
|
float64(len(snapshot))/(1<<20), verdict)
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
active, err := svc.GetActiveClientTraffics(activeEmails)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetActiveClientTraffics: %v", err)
|
||||||
|
}
|
||||||
|
fetchActive := time.Since(start)
|
||||||
|
if len(active) != k {
|
||||||
|
t.Fatalf("GetActiveClientTraffics rows = %d, want %d", len(active), k)
|
||||||
|
}
|
||||||
|
start = time.Now()
|
||||||
|
delta, err := json.Marshal(map[string]any{"clients": active})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal delta: %v", err)
|
||||||
|
}
|
||||||
|
marshalActive := time.Since(start)
|
||||||
|
t.Logf("N=%-7d delta(K=%d): fetch=%-9v marshal=%-9v payload=%.1fMB",
|
||||||
|
n, k, fetchActive.Round(time.Millisecond), marshalActive.Round(time.Millisecond),
|
||||||
|
float64(len(delta))/(1<<20))
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
lastOnline, err := svc.GetClientsLastOnline()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetClientsLastOnline: %v", err)
|
||||||
|
}
|
||||||
|
fullMap := time.Since(start)
|
||||||
|
if len(lastOnline) != n {
|
||||||
|
t.Fatalf("GetClientsLastOnline entries = %d, want %d", len(lastOnline), n)
|
||||||
|
}
|
||||||
|
start = time.Now()
|
||||||
|
activeLastOnline := make(map[string]int64, len(active))
|
||||||
|
for _, ct := range active {
|
||||||
|
activeLastOnline[ct.Email] = ct.LastOnline
|
||||||
|
}
|
||||||
|
activeMap := time.Since(start)
|
||||||
|
t.Logf("N=%-7d lastOnline: fullMap=%-9v activeMap(K=%d)=%v",
|
||||||
|
n, fullMap.Round(time.Millisecond), k, activeMap.Round(time.Microsecond))
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
if _, err := svc.GetInboundsTrafficSummary(); err != nil {
|
||||||
|
t.Fatalf("GetInboundsTrafficSummary: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("N=%-7d inboundsSummary=%v", n, time.Since(start).Round(time.Millisecond))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetXrayConfigScale measures building the full Xray config (the path
|
||||||
|
// every restart/reconcile takes) and, separately, how much of the per-inbound
|
||||||
|
// settings rebuild is spent on indented vs plain JSON marshaling.
|
||||||
|
func TestGetXrayConfigScale(t *testing.T) {
|
||||||
|
setupScaleDB(t)
|
||||||
|
svc := &XrayService{}
|
||||||
|
shapes := []struct {
|
||||||
|
name string
|
||||||
|
inbounds int
|
||||||
|
}{{"single", 1}, {"spread50", 50}}
|
||||||
|
|
||||||
|
for _, n := range scaleSizes(t, 10000, 100000) {
|
||||||
|
for _, shape := range shapes {
|
||||||
|
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
|
||||||
|
ds := seedScaleDataset(t, n, shape.inbounds)
|
||||||
|
|
||||||
|
const reps = 3
|
||||||
|
start := time.Now()
|
||||||
|
for range reps {
|
||||||
|
cfg, err := svc.GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetXrayConfig: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.InboundConfigs) < shape.inbounds {
|
||||||
|
t.Fatalf("config has %d inbounds, want >= %d", len(cfg.InboundConfigs), shape.inbounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("N=%-7d shape=%-8s GetXrayConfig=%v/run",
|
||||||
|
n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
|
||||||
|
|
||||||
|
var ib model.Inbound
|
||||||
|
if err := database.GetDB().First(&ib, ds.inboundIds[0]).Error; err != nil {
|
||||||
|
t.Fatalf("load inbound: %v", err)
|
||||||
|
}
|
||||||
|
settings := map[string]any{}
|
||||||
|
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
||||||
|
t.Fatalf("unmarshal settings: %v", err)
|
||||||
|
}
|
||||||
|
start = time.Now()
|
||||||
|
if _, err := json.Marshal(settings); err != nil {
|
||||||
|
t.Fatalf("marshal settings: %v", err)
|
||||||
|
}
|
||||||
|
plain := time.Since(start)
|
||||||
|
start = time.Now()
|
||||||
|
if _, err := json.MarshalIndent(settings, "", " "); err != nil {
|
||||||
|
t.Fatalf("marshal indent settings: %v", err)
|
||||||
|
}
|
||||||
|
indented := time.Since(start)
|
||||||
|
t.Logf("N=%-7d shape=%-8s settingsMarshal plain=%-9v indent=%v",
|
||||||
|
n, shape.name, plain.Round(time.Millisecond), indented.Round(time.Millisecond))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user