1
0

7 کامیت‌ها e079490144 ... 3cf3fddf12

نویسنده SHA1 پیام تاریخ
  n0ctal 3cf3fddf12 perf(db): add an index on settings.key (#5359) 20 ساعت پیش
  n0ctal 26cc4838ed perf(xray): compile log/traffic regexps once at package scope (#5362) 20 ساعت پیش
  MHSanaei a5bc71a6f1 fix(sub): SS2022 share links must not base64-encode userinfo (#5432) 22 ساعت پیش
  MHSanaei c58db81da0 fix(sub): add missing :// in Shadowrocket subscription deep link (#3945) 23 ساعت پیش
  MHSanaei 0a40ec5f13 fix(sub): re-add xhttp mode to extra JSON for Karing (#5446) 23 ساعت پیش
  MHSanaei 6d9fd4b41b fix(sub): {{INBOUND}} = inbound remark, fix {{TRAFFIC_LEFT}} across inbounds (#5443) 23 ساعت پیش
  MHSanaei 6a032bcb2a perf(scale): speed up traffic, auto-renew, and node bulk ops at 50k-100k clients 23 ساعت پیش
43فایلهای تغییر یافته به همراه820 افزوده شده و 205 حذف شده
  1. 17 0
      frontend/src/lib/xray/inbound-link.ts
  2. 9 2
      frontend/src/lib/xray/outbound-link-parser.ts
  3. 1 1
      frontend/src/pages/sub/SubPage.tsx
  4. 2 2
      frontend/src/test/__snapshots__/inbound-link.test.ts.snap
  5. 34 8
      internal/database/db.go
  6. 1 0
      internal/database/index_tags_test.go
  7. 1 1
      internal/database/model/model.go
  8. 30 0
      internal/database/settings_index_test.go
  9. 1 1
      internal/sub/characterization_test.go
  10. 2 1
      internal/sub/controller.go
  11. 18 12
      internal/sub/remark_vars.go
  12. 40 20
      internal/sub/remark_vars_test.go
  13. 38 5
      internal/sub/service.go
  14. 4 2
      internal/sub/service_test.go
  15. 6 1
      internal/util/link/outbound.go
  16. 21 0
      internal/web/runtime/manager.go
  17. 65 0
      internal/web/runtime/reconcile_skip_test.go
  18. 37 0
      internal/web/runtime/remote.go
  19. 4 27
      internal/web/service/api_scale_postgres_test.go
  20. 10 0
      internal/web/service/client_bulk.go
  21. 29 12
      internal/web/service/client_inbound_apply.go
  22. 104 0
      internal/web/service/inbound_autorenew_test.go
  23. 40 10
      internal/web/service/inbound_node.go
  24. 34 27
      internal/web/service/inbound_traffic.go
  25. 168 0
      internal/web/service/node_bulk_dispatch_test.go
  26. 64 0
      internal/web/service/scale_helpers_test.go
  27. 10 51
      internal/web/service/sync_scale_postgres_test.go
  28. 1 1
      internal/web/translation/ar-EG.json
  29. 1 1
      internal/web/translation/en-US.json
  30. 1 1
      internal/web/translation/es-ES.json
  31. 1 1
      internal/web/translation/fa-IR.json
  32. 1 1
      internal/web/translation/id-ID.json
  33. 1 1
      internal/web/translation/ja-JP.json
  34. 1 1
      internal/web/translation/pt-BR.json
  35. 1 1
      internal/web/translation/ru-RU.json
  36. 1 1
      internal/web/translation/tr-TR.json
  37. 1 1
      internal/web/translation/uk-UA.json
  38. 1 1
      internal/web/translation/vi-VN.json
  39. 1 1
      internal/web/translation/zh-CN.json
  40. 1 1
      internal/web/translation/zh-TW.json
  41. 7 3
      internal/xray/api.go
  42. 2 2
      internal/xray/client_traffic.go
  43. 8 4
      internal/xray/log_writer.go

+ 17 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -47,6 +47,10 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
   if (!xhttp) return null;
   const extra: Record<string, unknown> = {};
 
+  if (typeof xhttp.mode === 'string' && xhttp.mode.length > 0) {
+    extra.mode = xhttp.mode;
+  }
+
   if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
     extra.xPaddingBytes = xhttp.xPaddingBytes;
   }
@@ -613,6 +617,19 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
   if (isSS2022) passwords.push(settings.password);
   if (isSSMultiUser) passwords.push(clientPassword);
 
+  if (isSS2022) {
+    // SIP022 (2022-blake3-*) forbids base64 userinfo: method and each key are
+    // percent-encoded, joined by literal ':' separators. Built by hand because
+    // `new URL` would re-encode the inner key separator to %3A.
+    const userinfo = [settings.method, ...passwords].map(encodeURIComponent).join(':');
+    let link = `ss://${userinfo}@${formatUrlHost(address)}:${port}`;
+    const query = params.toString();
+    if (query) link += `?${query}`;
+    link += `#${encodeURIComponent(remark)}`;
+    return link;
+  }
+
+  // SIP002 userinfo is base64(method:pw).
   const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
   const url = new URL(`ss://${userinfo}@${formatUrlHost(address)}:${port}`);
   for (const [key, value] of params) url.searchParams.set(key, value);

+ 9 - 2
frontend/src/lib/xray/outbound-link-parser.ts

@@ -372,8 +372,15 @@ export function parseShadowsocksLink(link: string): Raw | null {
   const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
   const atIndex = core.indexOf('@');
   if (atIndex >= 0) {
-    try { userInfo = Base64.decode(core.slice('ss://'.length, atIndex)); }
-    catch { userInfo = core.slice('ss://'.length, atIndex); }
+    const rawUserInfo = core.slice('ss://'.length, atIndex);
+    if (rawUserInfo.includes(':')) {
+      // SIP022 (2022-blake3-*) userinfo is percent-encoded, never base64
+      // (a literal ':' can't appear in a base64/base64url string).
+      try { userInfo = decodeURIComponent(rawUserInfo); } catch { userInfo = rawUserInfo; }
+    } else {
+      try { userInfo = Base64.decode(rawUserInfo); }
+      catch { userInfo = rawUserInfo; }
+    }
     const hostPort = core.slice(atIndex + 1);
     const colon = hostPort.lastIndexOf(':');
     if (colon < 0) return null;

+ 1 - 1
frontend/src/pages/sub/SubPage.tsx

@@ -130,7 +130,7 @@ export default function SubPage() {
     const rawUrl = subUrl + separator + 'flag=shadowrocket';
     const base64Url = btoa(rawUrl);
     const remark = encodeURIComponent(subTitle || sId || 'Subscription');
-    return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
+    return `shadowrocket://add/sub://${base64Url}?remark=${remark}`;
   }, []);
 
   const v2boxUrl = useMemo(

+ 2 - 2
frontend/src/test/__snapshots__/inbound-link.test.ts.snap

@@ -4,7 +4,7 @@ exports[`genHysteriaLink > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-
 
 exports[`genInboundLinks orchestrator > hysteria-v1-tls: byte-stable 1`] = `"hysteria://[email protected]:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
 
-exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206Wm1GclpTMXpaWEoyWlhJdGNHRnpjM2R2Y21RdE1EQXdNUT09OmRHVnpkQzFqYkdsbGJuUXRjR0Z6YzNkdmNtUXRNUT09@override.test:8388?type=tcp#parity-test"`;
+exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@override.test:8388?type=tcp#parity-test"`;
 
 exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
 
@@ -32,7 +32,7 @@ PersistentKeepalive = 25
 "
 `;
 
-exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206Wm1GclpTMXpaWEoyWlhJdGNHRnpjM2R2Y21RdE1EQXdNUT09OmRHVnpkQzFqYkdsbGJuUXRjR0Z6YzNkdmNtUXRNUT09@example.test:8388?type=tcp#parity-test"`;
+exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@example.test:8388?type=tcp#parity-test"`;
 
 exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
 

+ 34 - 8
internal/database/db.go

@@ -6,6 +6,7 @@ import (
 	"bytes"
 	"encoding/json"
 	"errors"
+	"fmt"
 	"io"
 	"log"
 	"math"
@@ -886,7 +887,10 @@ func InitDB(dbPath string) error {
 		if err = os.MkdirAll(dir, 0755); err != nil {
 			return err
 		}
-		dsn := dbPath + "?_journal_mode=DELETE&_busy_timeout=10000&_synchronous=FULL&_txlock=immediate"
+		// Keep journal_mode=DELETE so the DB stays a single file (no -wal/-shm
+		// sidecars). synchronous defaults to FULL for durability but is tunable.
+		sync := sqliteSynchronous()
+		dsn := dbPath + "?_journal_mode=DELETE&_busy_timeout=10000&_synchronous=" + sync + "&_txlock=immediate"
 		db, err = gorm.Open(sqlite.Open(dsn), c)
 		if err != nil {
 			return err
@@ -895,14 +899,21 @@ func InitDB(dbPath string) error {
 		if err != nil {
 			return err
 		}
-		if _, err := sqlDB.Exec("PRAGMA journal_mode=DELETE"); err != nil {
-			return err
-		}
-		if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
-			return err
+		// Re-assert the DSN pragmas plus scan-friendly ones for large datasets.
+		// cache_size/mmap_size/temp_store create no extra files, so the single-file
+		// guarantee holds; they just cut disk I/O on the 50k-row hot paths.
+		pragmas := []string{
+			"PRAGMA journal_mode=DELETE",
+			"PRAGMA busy_timeout=10000",
+			"PRAGMA synchronous=" + sync,
+			fmt.Sprintf("PRAGMA cache_size=-%d", envInt("XUI_DB_CACHE_MB", 32)*1024),
+			fmt.Sprintf("PRAGMA mmap_size=%d", int64(envInt("XUI_DB_MMAP_MB", 256))*1024*1024),
+			"PRAGMA temp_store=MEMORY",
 		}
-		if _, err := sqlDB.Exec("PRAGMA synchronous=FULL"); err != nil {
-			return err
+		for _, p := range pragmas {
+			if _, err := sqlDB.Exec(p); err != nil {
+				return err
+			}
 		}
 	}
 
@@ -939,6 +950,21 @@ func InitDB(dbPath string) error {
 	return runSeeders(isUsersEmpty)
 }
 
+// sqliteSynchronous returns the SQLite synchronous mode, defaulting to FULL.
+// Whitelisted because the value is interpolated directly into a PRAGMA string.
+func sqliteSynchronous() string {
+	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
+	case "OFF":
+		return "OFF"
+	case "NORMAL":
+		return "NORMAL"
+	case "EXTRA":
+		return "EXTRA"
+	default:
+		return "FULL"
+	}
+}
+
 func envInt(key string, def int) int {
 	v := strings.TrimSpace(os.Getenv(key))
 	if v == "" {

+ 1 - 0
internal/database/index_tags_test.go

@@ -31,6 +31,7 @@ func TestAutoMigrateCreatesHotPathIndexes(t *testing.T) {
 	}{
 		{&model.ClientRecord{}, "idx_client_record_group"},
 		{&xray.ClientTraffic{}, "idx_client_traffics_inbound"},
+		{&xray.ClientTraffic{}, "idx_client_traffics_renew"},
 	}
 	for _, c := range cases {
 		if !db.Migrator().HasIndex(c.model, c.index) {

+ 1 - 1
internal/database/model/model.go

@@ -486,7 +486,7 @@ func HealMtprotoSecret(settings string) (string, bool) {
 // Setting stores key-value configuration settings for the 3x-ui panel.
 type Setting struct {
 	Id    int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
-	Key   string `json:"key" form:"key"`
+	Key   string `json:"key" form:"key" gorm:"index:idx_settings_key"`
 	Value string `json:"value" form:"value"`
 }
 

+ 30 - 0
internal/database/settings_index_test.go

@@ -0,0 +1,30 @@
+package database
+
+import (
+	"testing"
+
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// settings.key is read on nearly every request and job tick (getSetting
+// WHERE key=?); AutoMigrate must create the index so those lookups don't
+// full-scan the settings table past the large xrayTemplateConfig blob. gorm
+// creates missing indexes on migrate, so this also covers existing DBs.
+func TestAutoMigrateCreatesSettingsKeyIndex(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+		Logger: logger.Default.LogMode(logger.Silent),
+	})
+	if err != nil {
+		t.Fatalf("open sqlite: %v", err)
+	}
+	if err := db.AutoMigrate(&model.Setting{}); err != nil {
+		t.Fatalf("automigrate: %v", err)
+	}
+	if !db.Migrator().HasIndex(&model.Setting{}, "idx_settings_key") {
+		t.Errorf("expected idx_settings_key to exist after AutoMigrate")
+	}
+}

+ 1 - 1
internal/sub/characterization_test.go

@@ -168,7 +168,7 @@ func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
 	}
 	s := &SubService{}
 	got := s.genShadowsocksLink(in, "user")
-	want := "ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206aW5ib3VuZHB3OmNsaWVudHB3@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
+	want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
 	if got != want {
 		t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
 	}

+ 2 - 1
internal/sub/controller.go

@@ -149,7 +149,8 @@ func (a *SUBController) subs(c *gin.Context) {
 	} else {
 		var result strings.Builder
 		for _, sub := range subs {
-			result.WriteString(sub + "\n")
+			result.WriteString(sub)
+			result.WriteString("\n")
 		}
 
 		// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here

+ 18 - 12
internal/sub/remark_vars.go

@@ -15,8 +15,9 @@ import (
 // remarkContext carries the per-client data a remark template can interpolate.
 // stats holds the live traffic record when one exists; when it doesn't, the
 // caller synthesizes a minimal one from the client so expiry/total/status tokens
-// still resolve. hostRemark is the host endpoint's own remark: it takes priority
-// over the inbound's remark as the config name and backs the {{HOST}} token.
+// still resolve. hostRemark is the host endpoint's own remark; it backs the
+// {{HOST}} token only — it never substitutes the inbound's remark as the config
+// name (use {{INBOUND}} and {{HOST}} side by side to show both).
 type remarkContext struct {
 	client     model.Client
 	stats      xray.ClientTraffic
@@ -24,12 +25,9 @@ type remarkContext struct {
 	hostRemark string
 }
 
-// configName is the display name for a link: the host endpoint's own remark when
-// it has one, otherwise the inbound's remark.
+// configName is the display name for a link: always the inbound's own remark.
+// The host endpoint's remark is surfaced only through the {{HOST}} token.
 func (ctx remarkContext) configName() string {
-	if ctx.hostRemark != "" {
-		return ctx.hostRemark
-	}
 	if ctx.inbound != nil {
 		return ctx.inbound.Remark
 	}
@@ -227,6 +225,14 @@ func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client)
 	if stats, ok := s.findClientStats(inbound, client.Email); ok {
 		return stats
 	}
+	// client_traffics.email is globally unique, so a client shared across several
+	// inbounds of one subscription has a single traffic row owned by exactly one
+	// inbound. On every other inbound's link findClientStats misses; fall back to
+	// the per-request map built from all the subscription's inbounds so
+	// {{TRAFFIC_*}} reflect real usage instead of the full quota (#5443).
+	if stats, ok := s.statsByEmail[client.Email]; ok {
+		return stats
+	}
 	return xray.ClientTraffic{
 		Enable:     client.Enable,
 		ExpiryTime: client.ExpiryTime,
@@ -292,8 +298,8 @@ func (s *SubService) effectiveTemplate(email string) string {
 }
 
 // genTemplatedRemark expands the remark template for one client. hostRemark is
-// the host endpoint's remark (empty for a plain inbound); it takes priority over
-// the inbound remark for the config name and backs the {{HOST}} token.
+// the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
+// token only and never substitutes the inbound remark as the config name.
 func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
 	ctx := remarkContext{
 		client:     client,
@@ -311,9 +317,9 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
 }
 
 // genHostRemark builds one host endpoint's remark for a specific client. The
-// config name is the host endpoint's own remark when set, otherwise the inbound's
-// remark. In the subscription body the rest of the remark template still applies;
-// displays show just the config name.
+// config name is always the inbound's own remark; the host's remark is surfaced
+// only through the {{HOST}} token. In the subscription body the rest of the
+// remark template still applies; displays show just the config name.
 func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
 	if !s.subscriptionBody {
 		return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()

+ 40 - 20
internal/sub/remark_vars_test.go

@@ -165,30 +165,30 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
 	return s, inbound, client
 }
 
-// The config name prefers the host endpoint's own remark; the inbound's remark is
-// the fallback, used only when the host has none.
-func TestGenHostRemark_ConfigNameHostWins(t *testing.T) {
+// The config name is always the inbound's own remark; the host endpoint's remark
+// never substitutes it (it is reachable only through {{HOST}}).
+func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
 	s, inbound, client := hostRemarkService("") // no template → config name only
-	if got := s.genHostRemark(inbound, client, "Relay"); got != "Relay" {
-		t.Fatalf("genHostRemark = %q, want %q (host remark wins)", got, "Relay")
+	if got := s.genHostRemark(inbound, client, "Relay"); got != "DE" {
+		t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
 	}
 	if got := s.genHostRemark(inbound, client, ""); got != "DE" {
-		t.Fatalf("genHostRemark (no host remark) = %q, want %q (inbound fallback)", got, "DE")
+		t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
 	}
 }
 
-// In the body the template applies: {{INBOUND}} is the config name (host remark
-// first, inbound fallback) and {{HOST}} is always the host's own remark.
+// In the body the template applies: {{INBOUND}} is always the inbound's remark
+// and {{HOST}} the host's own remark, so the two can be shown side by side.
 func TestGenHostRemark_GlobalTemplate(t *testing.T) {
-	// Host remark set → {{INBOUND}} resolves to it (host wins over the inbound).
+	// {{INBOUND}} resolves to the inbound remark regardless of the host remark.
 	s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
-	if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN | 80.00GB | 10d" {
-		t.Fatalf("global template (host wins) = %q", got)
+	if got := s.genHostRemark(inbound, client, "CDN"); got != "DE | 80.00GB | 10d" {
+		t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
 	}
-	// No host remark → {{INBOUND}} falls back to the inbound's own remark.
-	s2, inbound2, client2 := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}}")
-	if got := s2.genHostRemark(inbound2, client2, ""); got != "DE | 80.00GB" {
-		t.Fatalf("global template (inbound fallback) = %q", got)
+	// {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
+	s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
+	if got := s2.genHostRemark(inbound2, client2, "CDN"); got != "DE|CDN|80.00GB" {
+		t.Fatalf("global template (inbound + host) = %q, want %q", got, "DE|CDN|80.00GB")
 	}
 	// {{HOST}} is the host's own remark even when the inbound has one of its own.
 	s3, inbound3, client3 := hostRemarkService("{{HOST}}")
@@ -239,12 +239,12 @@ func TestUsageOnFirstLinkOnly(t *testing.T) {
 func TestRemarkInDisplayContext(t *testing.T) {
 	s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
 	s.subscriptionBody = false
-	// A host link in a display shows only the config name — host remark wins, with
-	// no per-client email or usage info.
-	if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN" {
-		t.Fatalf("display host link = %q, want config name %q (host wins)", got, "CDN")
+	// A host link in a display shows only the config name — the inbound's remark,
+	// with no per-client email or usage info and the host remark ignored.
+	if got := s.genHostRemark(inbound, client, "CDN"); got != "DE" {
+		t.Fatalf("display host link = %q, want config name %q", got, "DE")
 	}
-	// With no host remark, the config name is the inbound's own remark.
+	// With no host remark, the config name is likewise the inbound's own remark.
 	if got := s.genHostRemark(inbound, client, ""); got != "DE" {
 		t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
 	}
@@ -270,6 +270,26 @@ func TestNameOnlyTemplate(t *testing.T) {
 	}
 }
 
+// statsForClient resolves usage from the per-request statsByEmail map when the
+// link's own inbound doesn't carry the client's (globally unique) traffic row —
+// the multi-inbound case that made {{TRAFFIC_LEFT}} show the full quota (#5443).
+func TestStatsForClient_CrossInboundFallback(t *testing.T) {
+	s := &SubService{
+		statsByEmail: map[string]xray.ClientTraffic{
+			"[email protected]": {Email: "[email protected]", Total: 100 * gb, Up: 15 * gb, Down: 5 * gb},
+		},
+	}
+	// Inbound B carries no ClientStats for john (his row is owned by inbound A).
+	inboundB := &model.Inbound{Remark: "B"}
+	st := s.statsForClient(inboundB, model.Client{Email: "[email protected]"})
+	if used := st.Up + st.Down; used != 20*gb {
+		t.Fatalf("statsForClient used = %d, want %d (cross-inbound fallback)", used, 20*gb)
+	}
+	if got := remarkVarValue("TRAFFIC_LEFT", remarkContext{stats: st}); got != "80.00GB" {
+		t.Fatalf("TRAFFIC_LEFT = %q, want 80.00GB (remaining, not total)", got)
+	}
+}
+
 // Two clients through the same global template get distinct, per-client remarks.
 func TestGenHostRemark_PerClient(t *testing.T) {
 	s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}

+ 38 - 5
internal/sub/service.go

@@ -47,6 +47,12 @@ type SubService struct {
 	// inbound whose NodeID is set. Keeps the per-link host derivation
 	// O(1) instead of O(N) DB hits.
 	nodesByID map[int]*model.Node
+	// statsByEmail maps a client email to its traffic row across ALL inbounds
+	// loaded for the request. client_traffics.email is globally unique, so this
+	// lets statsForClient resolve usage for a client even on an inbound that
+	// doesn't own its row (multi-inbound subscriptions). Filled in
+	// getInboundsBySubId; reset per request in PrepareForRequest.
+	statsByEmail map[string]xray.ClientTraffic
 }
 
 // NewSubService creates a new subscription service with the given configuration.
@@ -78,6 +84,7 @@ func (s *SubService) PrepareForRequest(host string) {
 	}
 	s.address = host
 	s.usageShown = map[string]bool{}
+	s.statsByEmail = map[string]xray.ClientTraffic{}
 	s.loadNodes()
 	s.loadRemarkSettings()
 }
@@ -335,9 +342,24 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
 	if err != nil {
 		return nil, err
 	}
+	s.indexStatsByEmail(inbounds)
 	return inbounds, nil
 }
 
+// indexStatsByEmail records every loaded inbound's client traffic rows keyed by
+// email so statsForClient can resolve a client's usage even on an inbound that
+// doesn't own its (globally unique) client_traffics row. See statsByEmail.
+func (s *SubService) indexStatsByEmail(inbounds []*model.Inbound) {
+	if s.statsByEmail == nil {
+		s.statsByEmail = map[string]xray.ClientTraffic{}
+	}
+	for _, inbound := range inbounds {
+		for _, st := range inbound.ClientStats {
+			s.statsByEmail[st.Email] = st
+		}
+	}
+}
+
 // projectThroughFallbackMaster mutates the inbound in place so its
 // Listen/Port/StreamSettings reflect the externally reachable master
 // when applicable. Covers both fallback mechanisms:
@@ -716,9 +738,16 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 		params["plugin"] = "obfs-local;obfs=http;obfs-host=" + host
 	}
 
-	encPart := fmt.Sprintf("%s:%s", method, clients[clientIndex].Password)
-	if method[0] == '2' {
-		encPart = fmt.Sprintf("%s:%s:%s", method, inboundPassword, clients[clientIndex].Password)
+	// SIP002 userinfo is base64(method:password). For SIP022 (2022-blake3-*) the
+	// userinfo MUST NOT be base64-encoded; method and password are percent-encoded.
+	var userInfo string
+	if strings.HasPrefix(method, "2022") {
+		userInfo = fmt.Sprintf("%s:%s:%s",
+			url.QueryEscape(method),
+			url.QueryEscape(inboundPassword),
+			url.QueryEscape(clients[clientIndex].Password))
+	} else {
+		userInfo = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", method, clients[clientIndex].Password)))
 	}
 
 	externalProxies, _ := stream["externalProxy"].([]any)
@@ -731,7 +760,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 			proxyParams,
 			security,
 			func(dest string, port int) string {
-				return fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(dest, port))
+				return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
 				return s.endpointRemark(inbound, email, ep)
@@ -739,7 +768,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 		)
 	}
 
-	link := fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(address, inbound.Port))
+	link := fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(address, inbound.Port))
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
 }
 
@@ -1635,6 +1664,10 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
 	}
 	extra := map[string]any{}
 
+	if mode, ok := xhttp["mode"].(string); ok && len(mode) > 0 {
+		extra["mode"] = mode
+	}
+
 	if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
 		extra["xPaddingBytes"] = xpb
 	}

+ 4 - 2
internal/sub/service_test.go

@@ -369,8 +369,10 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
 			t.Fatalf("extra missing %q: %#v", key, extra)
 		}
 	}
-	if _, ok := extra["mode"]; ok {
-		t.Fatalf("mode should stay as a top-level query parameter, got extra %#v", extra)
+	// mode rides inside extra (in addition to the flat param) so clients
+	// that only read the extra JSON keep the xhttp mode (#5446).
+	if extra["mode"] != "packet-up" {
+		t.Fatalf("extra[mode] = %#v, want packet-up", extra["mode"])
 	}
 
 	headers, ok := extra["headers"].(map[string]any)

+ 6 - 1
internal/util/link/outbound.go

@@ -344,7 +344,12 @@ func parseShadowsocks(link string) (*ParseResult, error) {
 		hp := core[at+1:]
 		userInfo, err := base64DecodeFlexible(userB64)
 		if err != nil {
-			userInfo = userB64 // not b64, rare
+			// SIP022 (2022-blake3-*) userinfo is percent-encoded, not base64.
+			if dec, uerr := url.QueryUnescape(userB64); uerr == nil {
+				userInfo = dec
+			} else {
+				userInfo = userB64 // not b64, rare
+			}
 		}
 		colon := strings.LastIndex(hp, ":")
 		if colon < 0 {

+ 21 - 0
internal/web/runtime/manager.go

@@ -17,6 +17,7 @@ type Manager struct {
 
 	mu             sync.RWMutex
 	remotes        map[int]*Remote
+	overrides      map[int]Runtime // test-only: forces RuntimeFor to return a stub
 	egressResolver NodeEgressResolver
 }
 
@@ -27,6 +28,22 @@ func NewManager(localDeps LocalDeps) *Manager {
 	}
 }
 
+// SetRuntimeOverride makes RuntimeFor(nodeID) return rt instead of building a
+// real Remote. Test seam for exercising node-dispatch paths without a network
+// node; pass nil rt to clear.
+func (m *Manager) SetRuntimeOverride(nodeID int, rt Runtime) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if rt == nil {
+		delete(m.overrides, nodeID)
+		return
+	}
+	if m.overrides == nil {
+		m.overrides = make(map[int]Runtime)
+	}
+	m.overrides[nodeID] = rt
+}
+
 func (m *Manager) SetNodeEgressResolver(r NodeEgressResolver) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
@@ -47,6 +64,10 @@ func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
 		return m.local, nil
 	}
 	m.mu.RLock()
+	if rt, ok := m.overrides[*nodeID]; ok {
+		m.mu.RUnlock()
+		return rt, nil
+	}
 	if rt, ok := m.remotes[*nodeID]; ok {
 		m.mu.RUnlock()
 		return rt, nil

+ 65 - 0
internal/web/runtime/reconcile_skip_test.go

@@ -0,0 +1,65 @@
+package runtime
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync/atomic"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestReconcileInbound_SkipsUnchanged proves the delta-skip: a second reconcile
+// of an unchanged inbound that the node still reports sends no push, while a
+// content change or an absent-on-node inbound forces a fresh push.
+func TestReconcileInbound_SkipsUnchanged(t *testing.T) {
+	var pushes atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/panel/api/inbounds/update/") {
+			pushes.Add(1)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true}`))
+	}))
+	defer srv.Close()
+
+	r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
+	ib := &model.Inbound{Tag: "in-1", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
+	// Pre-seed the tag→id cache so resolveRemoteID needs no network round-trip.
+	r.cacheSet(ib.Tag, 7)
+
+	// First reconcile: node doesn't report it yet → must push and record the fp.
+	if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
+		t.Fatalf("first reconcile: pushed=%v err=%v, want push", pushed, err)
+	}
+	if got := pushes.Load(); got != 1 {
+		t.Fatalf("after first reconcile pushes=%d, want 1", got)
+	}
+
+	// Second reconcile: unchanged and present on node → skip.
+	if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || pushed {
+		t.Fatalf("second reconcile: pushed=%v err=%v, want skip", pushed, err)
+	}
+	if got := pushes.Load(); got != 1 {
+		t.Fatalf("unchanged reconcile pushed again: pushes=%d, want 1", got)
+	}
+
+	// Content change → push again even though it's present on node.
+	ib.Settings = `{"clients":[{"email":"a@x"}]}`
+	if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
+		t.Fatalf("changed reconcile: pushed=%v err=%v, want push", pushed, err)
+	}
+	if got := pushes.Load(); got != 2 {
+		t.Fatalf("changed reconcile pushes=%d, want 2", got)
+	}
+
+	// Absent on node (e.g. node restarted/lost it) → re-push even if fp matches.
+	if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
+		t.Fatalf("absent-on-node reconcile: pushed=%v err=%v, want push", pushed, err)
+	}
+	if got := pushes.Load(); got != 3 {
+		t.Fatalf("absent-on-node reconcile pushes=%d, want 3", got)
+	}
+}

+ 37 - 0
internal/web/runtime/remote.go

@@ -3,6 +3,8 @@ package runtime
 import (
 	"bytes"
 	"context"
+	"crypto/sha256"
+	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -71,6 +73,10 @@ type Remote struct {
 
 	mu            sync.RWMutex
 	remoteIDByTag map[string]int
+	// pushedFP holds the fingerprint of the last inbound wire payload successfully
+	// pushed, keyed by panel-side tag, so reconcile can skip re-sending an
+	// unchanged inbound. Guarded by mu; dropped with the Remote on node config change.
+	pushedFP map[string]string
 	// supportsZstd is learned from the node's X-3x-Node-Caps response header; once
 	// seen, config pushes to this node are zstd-compressed. Old nodes never set
 	// it, so they keep receiving plain bodies (mixed-version safe).
@@ -96,6 +102,7 @@ func NewRemote(n *model.Node, r NodeEgressResolver) *Remote {
 	return &Remote{
 		node:           n,
 		remoteIDByTag:  make(map[string]int),
+		pushedFP:       make(map[string]string),
 		egressResolver: r,
 	}
 }
@@ -432,6 +439,36 @@ func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
 	return nil
 }
 
+// ReconcileInbound pushes ib only when its wire payload differs from the last
+// successful push, or when the node no longer reports the tag (existsOnNode
+// false) — a node that dropped/restarted must still be re-seeded. Returns
+// whether a push actually happened. This turns a full-fleet reconcile from "send
+// every inbound's full settings" into "send only what changed".
+func (r *Remote) ReconcileInbound(ctx context.Context, ib *model.Inbound, existsOnNode bool) (bool, error) {
+	fp := wireFingerprint(wireInbound(ib, r.node.Id))
+	if existsOnNode {
+		r.mu.RLock()
+		prev, ok := r.pushedFP[ib.Tag]
+		r.mu.RUnlock()
+		if ok && prev == fp {
+			return false, nil
+		}
+	}
+	if err := r.UpdateInbound(ctx, ib, ib); err != nil {
+		return false, err
+	}
+	r.mu.Lock()
+	r.pushedFP[ib.Tag] = fp
+	r.mu.Unlock()
+	return true, nil
+}
+
+// wireFingerprint hashes a wire payload so an unchanged inbound is cheap to detect.
+func wireFingerprint(v url.Values) string {
+	sum := sha256.Sum256([]byte(v.Encode()))
+	return hex.EncodeToString(sum[:])
+}
+
 func (r *Remote) AddUser(ctx context.Context, ib *model.Inbound, _ map[string]any) error {
 	return r.UpdateInbound(ctx, ib, ib)
 }

+ 4 - 27
internal/web/service/api_scale_postgres_test.go

@@ -2,17 +2,12 @@ package service
 
 import (
 	"fmt"
-	"os"
-	"strings"
 	"testing"
 	"time"
 
 	"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"
-
-	"github.com/op/go-logging"
 )
 
 func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
@@ -37,14 +32,7 @@ func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
 // reachable from the REST API at 100k/200k clients, asserting none crash on the
 // PostgreSQL bind-parameter ceiling and logging the wall-clock cost of each.
 func TestAllAPIsPostgresScale(t *testing.T) {
-	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
-		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
-	}
-	xuilogger.InitLogger(logging.ERROR)
-	if err := database.InitDB(""); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
+	setupScaleDB(t)
 
 	svc := &ClientService{}
 	inboundSvc := &InboundService{}
@@ -56,9 +44,7 @@ func TestAllAPIsPostgresScale(t *testing.T) {
 	for _, n := range sizes {
 		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
 			db := database.GetDB()
-			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics, client_groups RESTART IDENTITY CASCADE").Error; err != nil {
-				t.Fatalf("truncate: %v", err)
-			}
+			resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics", "client_groups")
 
 			clients := makeScaleClients(n)
 			exp := time.Now().AddDate(1, 0, 0).UnixMilli()
@@ -150,14 +136,7 @@ func TestAllAPIsPostgresScale(t *testing.T) {
 // old path (GetClientByEmail, which parses the inbound's entire settings JSON to
 // find one client) vs new path (UUID/subId read from the indexed clients table).
 func TestGetClientTrafficByEmailABScale(t *testing.T) {
-	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
-		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
-	}
-	xuilogger.InitLogger(logging.ERROR)
-	if err := database.InitDB(""); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
+	setupScaleDB(t)
 
 	svc := &ClientService{}
 	inboundSvc := &InboundService{}
@@ -179,9 +158,7 @@ func TestGetClientTrafficByEmailABScale(t *testing.T) {
 	for _, n := range sizes {
 		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
 			db := database.GetDB()
-			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
-				t.Fatalf("truncate: %v", err)
-			}
+			resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
 			clients := makeScaleClients(n)
 			ib := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("ctbe-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
 			if err := db.Create(ib).Error; err != nil {

+ 10 - 0
internal/web/service/client_bulk.go

@@ -525,6 +525,11 @@ func (s *ClientService) bulkAdjustInboundClients(
 			if dirty {
 				markDirty = true
 			}
+			// Large batches collapse into one reconcile push rather than M updates.
+			if push && len(foundEmails) > nodeBulkPushThreshold {
+				markDirty = true
+				push = false
+			}
 			if push {
 				for email := range foundEmails {
 					entry := plan[email]
@@ -911,6 +916,11 @@ func (s *ClientService) bulkDelInboundClients(
 			if dirty {
 				markDirty = true
 			}
+			// Large batches collapse into one reconcile push rather than M deletes.
+			if push && len(foundEmails) > nodeBulkPushThreshold {
+				markDirty = true
+				push = false
+			}
 			if push {
 				for email := range foundEmails {
 					if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {

+ 29 - 12
internal/web/service/client_inbound_apply.go

@@ -163,6 +163,25 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 		return needRestart, txErr
 	}
 
+	// Resolve the node push plan once for the whole batch instead of per email.
+	var nodeRt runtime.Runtime
+	nodePush := false
+	if oldInbound.NodeID != nil {
+		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+		if perr != nil {
+			return needRestart, perr
+		}
+		if dirty {
+			markDirty = true
+		}
+		nodeRt, nodePush = rt, push
+		// Large batches collapse into one reconcile push rather than M deletes.
+		if nodePush && len(targets) > nodeBulkPushThreshold {
+			markDirty = true
+			nodePush = false
+		}
+	}
+
 	// Apply runtime deletes after commit — outside the serialized writer so a
 	// slow node call can't stall traffic accounting.
 	for _, t := range targets {
@@ -180,20 +199,11 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 					}
 				}
 			}
-		} else {
-			rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
-			if perr != nil {
-				return needRestart, perr
-			}
-			if dirty {
+		} else if nodePush {
+			if err1 := nodeRt.DeleteUser(context.Background(), oldInbound, t.email); err1 != nil {
+				logger.Warning("Error in deleting client on", nodeRt.Name(), ":", err1)
 				markDirty = true
 			}
-			if push {
-				if err1 := rt.DeleteUser(context.Background(), oldInbound, t.email); err1 != nil {
-					logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
-					markDirty = true
-				}
-			}
 		}
 	}
 
@@ -402,6 +412,13 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 			}
 		}
 	} else {
+		// Large batches would be M sequential per-client RPCs; the inbound's saved
+		// settings already hold the final set, so mark dirty and let one reconcile
+		// push converge the node instead.
+		if push && len(clients) > nodeBulkPushThreshold {
+			markDirty = true
+			push = false
+		}
 		for _, client := range clients {
 			if push {
 				if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {

+ 104 - 0
internal/web/service/inbound_autorenew_test.go

@@ -0,0 +1,104 @@
+package service
+
+import (
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// TestAutoRenewClients_MultiInbound covers the renew loop across more than one
+// inbound: every expired client with reset>0 must get a fresh future expiry,
+// zeroed usage and re-enabled state, while a non-expiring client is untouched.
+// It also guards the map-lookup refactor of the old quadratic inner loop.
+func TestAutoRenewClients_MultiInbound(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-48 * time.Hour).UnixMilli()
+	future := time.Now().Add(365 * 24 * time.Hour).UnixMilli()
+
+	// Two inbounds, two expiring clients each, plus one client that never expires.
+	ib1Clients := []model.Client{
+		{Email: "a@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, Reset: 30, ExpiryTime: past},
+		{Email: "b@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, Reset: 30, ExpiryTime: past},
+	}
+	ib2Clients := []model.Client{
+		{Email: "c@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 7, ExpiryTime: past},
+		{Email: "keep@x", ID: "44444444-4444-4444-4444-444444444444", Enable: true, Reset: 0, ExpiryTime: future},
+	}
+
+	ib1 := mkInbound(t, 30001, model.VLESS, clientsSettings(t, ib1Clients))
+	ib2 := mkInbound(t, 30002, model.VLESS, clientsSettings(t, ib2Clients))
+	if err := svc.clientService.SyncInbound(nil, ib1.Id, ib1Clients); err != nil {
+		t.Fatalf("SyncInbound ib1: %v", err)
+	}
+	if err := svc.clientService.SyncInbound(nil, ib2.Id, ib2Clients); err != nil {
+		t.Fatalf("SyncInbound ib2: %v", err)
+	}
+
+	// Seed traffic rows: expired+depleted for the three renewable clients, and a
+	// healthy row for keep@x.
+	rows := []xray.ClientTraffic{
+		{InboundId: ib1.Id, Email: "a@x", Enable: false, Up: 100, Down: 200, Reset: 30, ExpiryTime: past},
+		{InboundId: ib1.Id, Email: "b@x", Enable: false, Up: 300, Down: 400, Reset: 30, ExpiryTime: past},
+		{InboundId: ib2.Id, Email: "c@x", Enable: false, Up: 500, Down: 600, Reset: 7, ExpiryTime: past},
+		{InboundId: ib2.Id, Email: "keep@x", Enable: true, Up: 1, Down: 2, Reset: 0, ExpiryTime: future},
+	}
+	if err := db.Create(&rows).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, count, err := svc.autoRenewClients(db); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	} else if count != 3 {
+		t.Fatalf("renewed count = %d, want 3", count)
+	}
+
+	now := time.Now().UnixMilli()
+	for _, email := range []string{"a@x", "b@x", "c@x"} {
+		var row xray.ClientTraffic
+		if err := db.Where("email = ?", email).First(&row).Error; err != nil {
+			t.Fatalf("read %s: %v", email, err)
+		}
+		if row.Up != 0 || row.Down != 0 {
+			t.Errorf("%s: usage not reset: up=%d down=%d", email, row.Up, row.Down)
+		}
+		if !row.Enable {
+			t.Errorf("%s: not re-enabled", email)
+		}
+		if row.ExpiryTime <= now {
+			t.Errorf("%s: expiry not advanced: got %d, now %d", email, row.ExpiryTime, now)
+		}
+	}
+
+	// The non-expiring client must be left exactly as seeded.
+	var keep xray.ClientTraffic
+	if err := db.Where("email = ?", "keep@x").First(&keep).Error; err != nil {
+		t.Fatalf("read keep@x: %v", err)
+	}
+	if keep.Up != 1 || keep.Down != 2 || keep.ExpiryTime != future {
+		t.Errorf("keep@x was modified: %+v", keep)
+	}
+
+	// The renewed state must also be reflected in the inbound settings JSON.
+	reloaded, err := svc.GetInbound(ib1.Id)
+	if err != nil {
+		t.Fatalf("GetInbound ib1: %v", err)
+	}
+	cs, err := svc.GetClients(reloaded)
+	if err != nil {
+		t.Fatalf("GetClients ib1: %v", err)
+	}
+	for _, c := range cs {
+		if !c.Enable {
+			t.Errorf("settings client %s still disabled after renew", c.Email)
+		}
+		if c.ExpiryTime <= now {
+			t.Errorf("settings client %s expiry not advanced: %d", c.Email, c.ExpiryTime)
+		}
+	}
+}

+ 40 - 10
internal/web/service/inbound_node.go

@@ -21,6 +21,12 @@ import (
 
 var reportedRemoteTagConflict sync.Map
 
+// nodeBulkPushThreshold caps how many per-client RPCs a single operation will
+// stream to a remote node. Above it, the panel marks the node dirty instead and
+// lets one ReconcileNode push converge the whole inbound — far cheaper than M
+// sequential round-trips. Small ops stay on the live per-client path.
+const nodeBulkPushThreshold = 32
+
 func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
 	mgr := runtime.GetManager()
 	if mgr == nil {
@@ -90,18 +96,31 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 	if err != nil {
 		return err
 	}
+	remoteTagSet := make(map[string]struct{}, len(remoteTags))
+	for _, tag := range remoteTags {
+		remoteTagSet[tag] = struct{}{}
+	}
 	prefix := nodeTagPrefix(&nodeID)
 	desiredTags := make(map[string]struct{}, len(inbounds)*2)
 	for _, ib := range inbounds {
 		desiredTags[ib.Tag] = struct{}{}
+		// existsOnNode: does the node already report this inbound under any of the
+		// tag forms it may be stored as? If so, an unchanged push can be skipped.
+		_, existsOnNode := remoteTagSet[ib.Tag]
 		if prefix != "" {
 			if stripped, found := strings.CutPrefix(ib.Tag, prefix); found {
 				desiredTags[stripped] = struct{}{}
+				if _, ok := remoteTagSet[stripped]; ok {
+					existsOnNode = true
+				}
 			} else {
 				desiredTags[prefix+ib.Tag] = struct{}{}
+				if _, ok := remoteTagSet[prefix+ib.Tag]; ok {
+					existsOnNode = true
+				}
 			}
 		}
-		if err := rt.UpdateInbound(ctx, ib, ib); err != nil {
+		if _, err := rt.ReconcileInbound(ctx, ib, existsOnNode); err != nil {
 			return fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)
 		}
 	}
@@ -260,15 +279,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		nodeBaselines[baselineRows[i].Email] = nodeTrafficCounter{Up: baselineRows[i].Up, Down: baselineRows[i].Down}
 	}
 
-	var existingEmailsList []string
-	if err := db.Model(xray.ClientTraffic{}).Pluck("email", &existingEmailsList).Error; err != nil {
-		return false, err
-	}
-	existingEmails := make(map[string]struct{}, len(existingEmailsList))
-	for _, e := range existingEmailsList {
-		existingEmails[e] = struct{}{}
-	}
-
 	var defaultUserId int
 	if len(central) > 0 {
 		defaultUserId = central[0].UserId
@@ -312,6 +322,26 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		}
 	}
 
+	// Membership set for the rowExists checks below. Only the snapshot's emails
+	// are ever probed, so scope the lookup to those instead of plucking the whole
+	// client_traffics table (50k+ rows) on every node poll.
+	existingEmails := make(map[string]struct{}, len(snapEmailsAll))
+	if len(snapEmailsAll) > 0 {
+		snapEmailList := make([]string, 0, len(snapEmailsAll))
+		for email := range snapEmailsAll {
+			snapEmailList = append(snapEmailList, email)
+		}
+		for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) {
+			var found []string
+			if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Pluck("email", &found).Error; err != nil {
+				return false, err
+			}
+			for _, e := range found {
+				existingEmails[e] = struct{}{}
+			}
+		}
+	}
+
 	tx := db.Begin()
 	committed := false
 	defer func() {

+ 34 - 27
internal/web/service/inbound_traffic.go

@@ -348,6 +348,13 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 		}
 		inbounds = append(inbounds, page...)
 	}
+	// Index the expired traffics by email so each client is an O(1) lookup
+	// instead of a linear scan of every expired row (O(clients × expired) per
+	// inbound, quadratic at scale). Pointers keep the in-place mutation below.
+	trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics))
+	for i := range traffics {
+		trafficByEmail[traffics[i].Email] = traffics[i]
+	}
 	for inbound_index := range inbounds {
 		settings := map[string]any{}
 		json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
@@ -357,34 +364,34 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 		}
 		for client_index := range clients {
 			c := clients[client_index].(map[string]any)
-			for traffic_index, traffic := range traffics {
-				if traffic.Email == c["email"].(string) {
-					newExpiryTime := traffic.ExpiryTime
-					for newExpiryTime < now {
-						newExpiryTime += (int64(traffic.Reset) * 86400000)
-					}
-					c["expiryTime"] = newExpiryTime
-					traffics[traffic_index].ExpiryTime = newExpiryTime
-					traffics[traffic_index].Down = 0
-					traffics[traffic_index].Up = 0
-					if !traffic.Enable {
-						traffics[traffic_index].Enable = true
-						c["enable"] = true
-						clientsToAdd = append(clientsToAdd,
-							struct {
-								protocol string
-								tag      string
-								client   map[string]any
-							}{
-								protocol: string(inbounds[inbound_index].Protocol),
-								tag:      inbounds[inbound_index].Tag,
-								client:   c,
-							})
-					}
-					clients[client_index] = any(c)
-					break
-				}
+			email, _ := c["email"].(string)
+			traffic, ok := trafficByEmail[email]
+			if !ok {
+				continue
+			}
+			newExpiryTime := traffic.ExpiryTime
+			for newExpiryTime < now {
+				newExpiryTime += (int64(traffic.Reset) * 86400000)
+			}
+			c["expiryTime"] = newExpiryTime
+			traffic.ExpiryTime = newExpiryTime
+			traffic.Down = 0
+			traffic.Up = 0
+			if !traffic.Enable {
+				traffic.Enable = true
+				c["enable"] = true
+				clientsToAdd = append(clientsToAdd,
+					struct {
+						protocol string
+						tag      string
+						client   map[string]any
+					}{
+						protocol: string(inbounds[inbound_index].Protocol),
+						tag:      inbounds[inbound_index].Tag,
+						client:   c,
+					})
 			}
+			clients[client_index] = any(c)
 		}
 		settings["clients"] = clients
 		newSettings, err := json.MarshalIndent(settings, "", "  ")

+ 168 - 0
internal/web/service/node_bulk_dispatch_test.go

@@ -0,0 +1,168 @@
+package service
+
+import (
+	"context"
+	"fmt"
+	"sync/atomic"
+	"testing"
+
+	"github.com/google/uuid"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+// fakeNodeRuntime is a runtime.Runtime stub that counts the per-client dispatch
+// calls so a test can assert a bulk op does NOT stream one RPC per client.
+type fakeNodeRuntime struct {
+	addClient  atomic.Int32
+	deleteUser atomic.Int32
+	updateUser atomic.Int32
+}
+
+func (f *fakeNodeRuntime) Name() string { return "fake-node" }
+
+func (f *fakeNodeRuntime) AddInbound(context.Context, *model.Inbound) error { return nil }
+func (f *fakeNodeRuntime) DelInbound(context.Context, *model.Inbound) error { return nil }
+func (f *fakeNodeRuntime) UpdateInbound(context.Context, *model.Inbound, *model.Inbound) error {
+	return nil
+}
+func (f *fakeNodeRuntime) AddUser(context.Context, *model.Inbound, map[string]any) error { return nil }
+func (f *fakeNodeRuntime) RemoveUser(context.Context, *model.Inbound, string) error      { return nil }
+func (f *fakeNodeRuntime) UpdateUser(context.Context, *model.Inbound, string, model.Client) error {
+	f.updateUser.Add(1)
+	return nil
+}
+func (f *fakeNodeRuntime) DeleteUser(context.Context, *model.Inbound, string) error {
+	f.deleteUser.Add(1)
+	return nil
+}
+func (f *fakeNodeRuntime) AddClient(context.Context, *model.Inbound, model.Client) error {
+	f.addClient.Add(1)
+	return nil
+}
+func (f *fakeNodeRuntime) RestartXray(context.Context) error { return nil }
+func (f *fakeNodeRuntime) ResetClientTraffic(context.Context, *model.Inbound, string) error {
+	return nil
+}
+func (f *fakeNodeRuntime) ResetInboundTraffic(context.Context, *model.Inbound) error { return nil }
+func (f *fakeNodeRuntime) ResetAllTraffics(context.Context) error                    { return nil }
+
+// setupNodeRuntime wires an online node + a fake runtime override and returns the
+// node id and the fake so a test can drive the service node-dispatch path without
+// a network node.
+func setupNodeRuntime(t *testing.T) (int, *fakeNodeRuntime) {
+	t.Helper()
+	prev := runtime.GetManager()
+	mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
+	runtime.SetManager(mgr)
+	t.Cleanup(func() { runtime.SetManager(prev) })
+
+	node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
+	if err := database.GetDB().Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	fake := &fakeNodeRuntime{}
+	mgr.SetRuntimeOverride(node.Id, fake)
+	return node.Id, fake
+}
+
+func nodeInbound(t *testing.T, nodeID, port int, clients []model.Client) *model.Inbound {
+	t.Helper()
+	if clients == nil {
+		clients = []model.Client{}
+	}
+	ib := &model.Inbound{
+		UserId: 1, NodeID: &nodeID, Tag: fmt.Sprintf("in-%d", port), Enable: true,
+		Port: port, Protocol: model.VLESS, Settings: clientsSettings(t, clients),
+	}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create node inbound: %v", err)
+	}
+	if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed SyncInbound: %v", err)
+	}
+	return ib
+}
+
+func makeNodeClients(n int) []model.Client {
+	out := make([]model.Client, n)
+	for i := range n {
+		out[i] = model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("nu-%05d@x", i), Enable: true}
+	}
+	return out
+}
+
+// TestNodeBulk_LargeAddFoldsToDirty: adding more than the threshold of clients to
+// an online node inbound must NOT stream one AddClient RPC per client; it marks
+// the node dirty so a single reconcile push converges it instead.
+func TestNodeBulk_LargeAddFoldsToDirty(t *testing.T) {
+	setupBulkDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+	ib := nodeInbound(t, nodeID, 30001, nil)
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	add := makeNodeClients(nodeBulkPushThreshold + 10)
+	if _, err := svc.AddInboundClient(inboundSvc, &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, add)}); err != nil {
+		t.Fatalf("AddInboundClient: %v", err)
+	}
+
+	if got := fake.addClient.Load(); got != 0 {
+		t.Fatalf("large add streamed %d AddClient RPCs, want 0 (should fold to dirty)", got)
+	}
+	if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil {
+		t.Fatalf("NodeSyncState: %v", err)
+	} else if !dirty {
+		t.Fatal("large add must mark the node dirty")
+	}
+}
+
+// TestNodeBulk_SmallAddPushesLive: a small add stays on the live per-client path.
+func TestNodeBulk_SmallAddPushesLive(t *testing.T) {
+	setupBulkDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+	ib := nodeInbound(t, nodeID, 30002, nil)
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const small = 3
+	add := makeNodeClients(small)
+	if _, err := svc.AddInboundClient(inboundSvc, &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, add)}); err != nil {
+		t.Fatalf("AddInboundClient: %v", err)
+	}
+	if got := fake.addClient.Load(); got != int32(small) {
+		t.Fatalf("small add streamed %d AddClient RPCs, want %d", got, small)
+	}
+}
+
+// TestNodeBulk_LargeDeleteFoldsToDirty: deleting more than the threshold from an
+// online node inbound must fold into a reconcile rather than per-client deletes.
+func TestNodeBulk_LargeDeleteFoldsToDirty(t *testing.T) {
+	setupBulkDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+
+	seed := makeNodeClients(nodeBulkPushThreshold + 10)
+	nodeInbound(t, nodeID, 30003, seed)
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+	emails := make([]string, len(seed))
+	for i := range seed {
+		emails[i] = seed[i].Email
+	}
+	if _, _, err := svc.BulkDelete(inboundSvc, emails, false); err != nil {
+		t.Fatalf("BulkDelete: %v", err)
+	}
+
+	if got := fake.deleteUser.Load(); got != 0 {
+		t.Fatalf("large delete streamed %d DeleteUser RPCs, want 0 (should fold to dirty)", got)
+	}
+	if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil {
+		t.Fatalf("NodeSyncState: %v", err)
+	} else if !dirty {
+		t.Fatal("large delete must mark the node dirty")
+	}
+}

+ 64 - 0
internal/web/service/scale_helpers_test.go

@@ -0,0 +1,64 @@
+package service
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+
+	"github.com/op/go-logging"
+	"gorm.io/gorm"
+)
+
+// 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),
+// and registers cleanup. Skips the test when neither backend is configured.
+func setupScaleDB(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":
+		dbPath := filepath.Join(t.TempDir(), "scale.db")
+		if err := database.InitDB(dbPath); 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")
+}
+
+// resetScaleTables empties the given tables between sub-sizes. Postgres uses a
+// single TRUNCATE ... CASCADE; SQLite deletes per table and clears the
+// autoincrement counters so ids restart like RESTART IDENTITY.
+func resetScaleTables(t *testing.T, db *gorm.DB, tables ...string) {
+	t.Helper()
+	if config.GetDBKind() == "postgres" {
+		stmt := "TRUNCATE TABLE " + strings.Join(tables, ", ") + " RESTART IDENTITY CASCADE"
+		if err := db.Exec(stmt).Error; err != nil {
+			t.Fatalf("truncate: %v", err)
+		}
+		return
+	}
+	for _, tbl := range tables {
+		if err := db.Exec("DELETE FROM " + tbl).Error; err != nil {
+			t.Fatalf("delete %s: %v", tbl, err)
+		}
+	}
+	// Best-effort id reset; sqlite_sequence is absent until the first insert.
+	db.Exec("DELETE FROM sqlite_sequence")
+}

+ 10 - 51
internal/web/service/sync_scale_postgres_test.go

@@ -3,7 +3,6 @@ package service
 import (
 	"errors"
 	"fmt"
-	"os"
 	"strings"
 	"testing"
 	"time"
@@ -82,13 +81,7 @@ func makeScaleClients(n int) []model.Client {
 }
 
 func TestSyncInboundPostgresScale(t *testing.T) {
-	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
-		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
-	}
-	if err := database.InitDB(""); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
+	setupScaleDB(t)
 
 	svc := &ClientService{}
 	sizes := []int{5000, 10000, 20000, 50000, 100000, 200000}
@@ -96,9 +89,7 @@ func TestSyncInboundPostgresScale(t *testing.T) {
 	for _, n := range sizes {
 		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
 			db := database.GetDB()
-			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
-				t.Fatalf("truncate: %v", err)
-			}
+			resetScaleTables(t, db, "inbounds", "clients", "client_inbounds")
 
 			clients := makeScaleClients(n)
 			ib := &model.Inbound{
@@ -168,13 +159,7 @@ func maxDur(d, floor time.Duration) time.Duration {
 }
 
 func TestAddDelClientPostgresScale(t *testing.T) {
-	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
-		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
-	}
-	if err := database.InitDB(""); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
+	setupScaleDB(t)
 
 	svc := &ClientService{}
 	inboundSvc := &InboundService{}
@@ -183,9 +168,7 @@ func TestAddDelClientPostgresScale(t *testing.T) {
 	for _, n := range sizes {
 		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
 			db := database.GetDB()
-			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
-				t.Fatalf("truncate: %v", err)
-			}
+			resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
 
 			clients := makeScaleClients(n)
 			ib := &model.Inbound{
@@ -233,13 +216,7 @@ func TestAddDelClientPostgresScale(t *testing.T) {
 }
 
 func TestGroupAndListPostgresScale(t *testing.T) {
-	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
-		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
-	}
-	if err := database.InitDB(""); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
+	setupScaleDB(t)
 
 	svc := &ClientService{}
 	sizes := []int{5000, 100000}
@@ -247,9 +224,7 @@ func TestGroupAndListPostgresScale(t *testing.T) {
 	for _, n := range sizes {
 		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
 			db := database.GetDB()
-			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
-				t.Fatalf("truncate: %v", err)
-			}
+			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 {
@@ -293,13 +268,7 @@ func TestGroupAndListPostgresScale(t *testing.T) {
 }
 
 func TestDelAllClientsPostgresScale(t *testing.T) {
-	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
-		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
-	}
-	if err := database.InitDB(""); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
+	setupScaleDB(t)
 
 	svc := &ClientService{}
 	inboundSvc := &InboundService{}
@@ -308,9 +277,7 @@ func TestDelAllClientsPostgresScale(t *testing.T) {
 	for _, n := range sizes {
 		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
 			db := database.GetDB()
-			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
-				t.Fatalf("truncate: %v", err)
-			}
+			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 {
@@ -343,13 +310,7 @@ func TestDelAllClientsPostgresScale(t *testing.T) {
 }
 
 func TestBulkOpsPostgresScale(t *testing.T) {
-	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
-		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
-	}
-	if err := database.InitDB(""); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = database.CloseDB() })
+	setupScaleDB(t)
 
 	svc := &ClientService{}
 	inboundSvc := &InboundService{}
@@ -359,9 +320,7 @@ func TestBulkOpsPostgresScale(t *testing.T) {
 	for _, n := range sizes {
 		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
 			db := database.GetDB()
-			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
-				t.Fatalf("truncate: %v", err)
-			}
+			resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
 
 			clients := makeScaleClients(n)
 			exp := time.Now().AddDate(1, 0, 0).UnixMilli()

+ 1 - 1
internal/web/translation/ar-EG.json

@@ -1761,7 +1761,7 @@
           "time": "الوقت والحالة"
         },
         "descEMAIL": "بريد العميل",
-        "descINBOUND": "اسم الإعداد: ملاحظة المضيف عند تعيينها، وإلا ملاحظة الوارد",
+        "descINBOUND": "ملاحظة الوارد نفسه (اسم الإعداد)",
         "descHOST": "ملاحظة المضيف",
         "descID": "UUID العميل",
         "descSHORT_ID": "أول 8 أحرف من الـ UUID",

+ 1 - 1
internal/web/translation/en-US.json

@@ -951,7 +951,7 @@
           "time": "Time & status"
         },
         "descEMAIL": "Client email",
-        "descINBOUND": "Config name: the host's remark when set, otherwise the inbound's remark",
+        "descINBOUND": "Inbound's own remark (the config name)",
         "descHOST": "Host remark",
         "descID": "Client UUID",
         "descSHORT_ID": "First 8 characters of the UUID",

+ 1 - 1
internal/web/translation/es-ES.json

@@ -1761,7 +1761,7 @@
           "time": "Tiempo y estado"
         },
         "descEMAIL": "Email del cliente",
-        "descINBOUND": "Nombre de la configuración: las notas del host cuando están definidas, de lo contrario las notas del inbound",
+        "descINBOUND": "Notas del propio inbound (nombre de la configuración)",
         "descHOST": "Notas del host",
         "descID": "UUID del cliente",
         "descSHORT_ID": "Primeros 8 caracteres del UUID",

+ 1 - 1
internal/web/translation/fa-IR.json

@@ -1761,7 +1761,7 @@
           "time": "زمان و وضعیت"
         },
         "descEMAIL": "ایمیل کاربر",
-        "descINBOUND": "نام کانفیگ: نام میزبان در صورت تنظیم، در غیر این صورت نام اینباند",
+        "descINBOUND": "نام خود اینباند (نام کانفیگ)",
         "descHOST": "نام میزبان",
         "descID": "UUID کاربر",
         "descSHORT_ID": "۸ کاراکتر اول UUID",

+ 1 - 1
internal/web/translation/id-ID.json

@@ -1761,7 +1761,7 @@
           "time": "Waktu & status"
         },
         "descEMAIL": "Email klien",
-        "descINBOUND": "Nama konfigurasi: catatan host bila diatur, jika tidak catatan inbound",
+        "descINBOUND": "Catatan inbound itu sendiri (nama konfigurasi)",
         "descHOST": "Catatan host",
         "descID": "UUID klien",
         "descSHORT_ID": "8 karakter pertama dari UUID",

+ 1 - 1
internal/web/translation/ja-JP.json

@@ -1761,7 +1761,7 @@
           "time": "時刻とステータス"
         },
         "descEMAIL": "クライアントのメール",
-        "descINBOUND": "設定名: ホストの備考が設定されている場合はそれ、それ以外はインバウンドの備考",
+        "descINBOUND": "インバウンド自身の備考(設定名)",
         "descHOST": "ホストの備考",
         "descID": "クライアント UUID",
         "descSHORT_ID": "UUID の最初の 8 文字",

+ 1 - 1
internal/web/translation/pt-BR.json

@@ -1761,7 +1761,7 @@
           "time": "Tempo e status"
         },
         "descEMAIL": "Email do cliente",
-        "descINBOUND": "Nome da configuração: a observação do host quando definida, caso contrário a observação da entrada",
+        "descINBOUND": "Observação da própria entrada (nome da configuração)",
         "descHOST": "Observação do host",
         "descID": "UUID do cliente",
         "descSHORT_ID": "Primeiros 8 caracteres do UUID",

+ 1 - 1
internal/web/translation/ru-RU.json

@@ -1761,7 +1761,7 @@
           "time": "Время и статус"
         },
         "descEMAIL": "Email клиента",
-        "descINBOUND": "Имя конфигурации: примечание хоста, если задано, иначе примечание входящего",
+        "descINBOUND": "Собственное примечание входящего (имя конфигурации)",
         "descHOST": "Примечание хоста",
         "descID": "UUID клиента",
         "descSHORT_ID": "Первые 8 символов UUID",

+ 1 - 1
internal/web/translation/tr-TR.json

@@ -1761,7 +1761,7 @@
           "time": "Zaman ve durum"
         },
         "descEMAIL": "Kullanıcı e-postası",
-        "descINBOUND": "Yapılandırma adı: ayarlanmışsa host'un açıklaması, aksi halde gelen bağlantının açıklaması",
+        "descINBOUND": "Gelen bağlantının kendi açıklaması (yapılandırma adı)",
         "descHOST": "Host açıklaması",
         "descID": "Kullanıcı UUID'si",
         "descSHORT_ID": "UUID'nin ilk 8 karakteri",

+ 1 - 1
internal/web/translation/uk-UA.json

@@ -1761,7 +1761,7 @@
           "time": "Час і статус"
         },
         "descEMAIL": "Email клієнта",
-        "descINBOUND": "Назва конфігурації: примітка хоста, якщо задана, інакше примітка вхідного",
+        "descINBOUND": "Власна примітка вхідного (назва конфігурації)",
         "descHOST": "Примітка хоста",
         "descID": "UUID клієнта",
         "descSHORT_ID": "Перші 8 символів UUID",

+ 1 - 1
internal/web/translation/vi-VN.json

@@ -1761,7 +1761,7 @@
           "time": "Thời gian & trạng thái"
         },
         "descEMAIL": "Email khách hàng",
-        "descINBOUND": "Tên cấu hình: ghi chú của host nếu được đặt, nếu không thì ghi chú của inbound",
+        "descINBOUND": "Ghi chú của chính inbound (tên cấu hình)",
         "descHOST": "Ghi chú host",
         "descID": "UUID khách hàng",
         "descSHORT_ID": "8 ký tự đầu của UUID",

+ 1 - 1
internal/web/translation/zh-CN.json

@@ -1761,7 +1761,7 @@
           "time": "时间与状态"
         },
         "descEMAIL": "客户端邮箱",
-        "descINBOUND": "配置名称:已设置时为主机的备注,否则为入站的备注",
+        "descINBOUND": "入站本身的备注(配置名称)",
         "descHOST": "主机备注",
         "descID": "客户端 UUID",
         "descSHORT_ID": "UUID 的前 8 个字符",

+ 1 - 1
internal/web/translation/zh-TW.json

@@ -1761,7 +1761,7 @@
           "time": "時間與狀態"
         },
         "descEMAIL": "客戶端電子郵件",
-        "descINBOUND": "配置名稱:設定時為 Host 的備註,否則為入站的備註",
+        "descINBOUND": "入站本身的備註(配置名稱)",
         "descHOST": "Host 備註",
         "descID": "客戶端 UUID",
         "descSHORT_ID": "UUID 的前 8 個字元",

+ 7 - 3
internal/xray/api.go

@@ -38,6 +38,13 @@ import (
 	"google.golang.org/grpc/status"
 )
 
+// Compiled once at package load: GetTraffic runs on every traffic-stats tick,
+// so recompiling these per call is wasted work.
+var (
+	trafficRegex       = regexp.MustCompile(`(inbound|outbound)>>>([^>]+)>>>traffic>>>(downlink|uplink)`)
+	clientTrafficRegex = regexp.MustCompile(`user>>>([^>]+)>>>traffic>>>(downlink|uplink)`)
+)
+
 // XrayAPI is a gRPC client for managing Xray core configuration, inbounds, outbounds, and statistics.
 type XrayAPI struct {
 	HandlerServiceClient *command.HandlerServiceClient
@@ -537,9 +544,6 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
 		return nil, nil, common.NewError("xray api is not initialized")
 	}
 
-	trafficRegex := regexp.MustCompile(`(inbound|outbound)>>>([^>]+)>>>traffic>>>(downlink|uplink)`)
-	clientTrafficRegex := regexp.MustCompile(`user>>>([^>]+)>>>traffic>>>(downlink|uplink)`)
-
 	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
 	defer cancel()
 

+ 2 - 2
internal/xray/client_traffic.go

@@ -11,8 +11,8 @@ type ClientTraffic struct {
 	SubId      string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
 	Up         int64  `json:"up" form:"up" example:"1048576"`
 	Down       int64  `json:"down" form:"down" example:"2097152"`
-	ExpiryTime int64  `json:"expiryTime" form:"expiryTime" example:"1735689600000"`
+	ExpiryTime int64  `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"`
 	Total      int64  `json:"total" form:"total" example:"10737418240"`
-	Reset      int    `json:"reset" form:"reset" gorm:"default:0" example:"0"`
+	Reset      int    `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"`
 	LastOnline int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
 }

+ 8 - 4
internal/xray/log_writer.go

@@ -8,6 +8,13 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
+// Compiled once at package load: Write runs on every line Xray emits, so
+// recompiling these per write is wasted work.
+var (
+	crashRegex   = regexp.MustCompile(`(?i)(panic|exception|stack trace|fatal error)`)
+	logLineRegex = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) \[([^\]]+)\] (.+)$`)
+)
+
 // NewLogWriter returns a new LogWriter for processing Xray log output.
 func NewLogWriter() *LogWriter {
 	return &LogWriter{}
@@ -20,8 +27,6 @@ type LogWriter struct {
 
 // Write processes and filters log output from the Xray process, handling crash detection and message filtering.
 func (lw *LogWriter) Write(m []byte) (n int, err error) {
-	crashRegex := regexp.MustCompile(`(?i)(panic|exception|stack trace|fatal error)`)
-
 	// Convert the data to a string
 	message := strings.TrimSpace(string(m))
 	msgLowerAll := strings.ToLower(message)
@@ -42,11 +47,10 @@ func (lw *LogWriter) Write(m []byte) (n int, err error) {
 		return len(m), nil
 	}
 
-	regex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) \[([^\]]+)\] (.+)$`)
 	messages := strings.SplitSeq(message, "\n")
 
 	for msg := range messages {
-		matches := regex.FindStringSubmatch(msg)
+		matches := logLineRegex.FindStringSubmatch(msg)
 
 		if len(matches) > 3 {
 			level := matches[2]