mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-26 15:55:02 +03:00
perf(ip-sync): avoid redundant client IP reads and writes (#6930)
The access-log scanner and the cross-node merge stored the same IP list in opposite orders, so neither saw a row as unchanged, and the merge re-read every preloaded row and re-upserted every attribution row each sync cycle. All writers now share one canonical encoder (timestamp, then IP ascending), the first CAS is seeded from the preload, and identical attribution rows are skipped. A create race between the scanner and node sync now merges into the winning row instead of dropping the loser's IPs, which on PostgreSQL also aborted the scanner's whole scan transaction. Closes #6927
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
const settleTimeout = 30 * time.Second
|
||||
@@ -196,6 +197,32 @@ func (p *panel) deleteInboundRow(id int) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *panel) seedClientIpAttribution(email, ips string) string {
|
||||
p.t.Helper()
|
||||
if p.cmd != nil {
|
||||
p.t.Fatalf("%s: seedClientIpAttribution on a running panel", p.name)
|
||||
}
|
||||
sharedDBMu.Lock()
|
||||
defer sharedDBMu.Unlock()
|
||||
if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
|
||||
p.t.Fatalf("%s: open db: %v", p.name, err)
|
||||
}
|
||||
defer func() { _ = database.CloseDB() }()
|
||||
db := database.GetDB()
|
||||
var setting model.Setting
|
||||
if err := db.Where("key = ?", "panelGuid").First(&setting).Error; err != nil {
|
||||
p.t.Fatalf("%s: read panelGuid: %v", p.name, err)
|
||||
}
|
||||
if setting.Value == "" {
|
||||
p.t.Fatalf("%s: empty panelGuid", p.name)
|
||||
}
|
||||
row := model.NodeClientIp{NodeGuid: setting.Value, Email: email, Ips: ips}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
p.t.Fatalf("%s: seed attribution: %v", p.name, err)
|
||||
}
|
||||
return setting.Value
|
||||
}
|
||||
|
||||
func (p *panel) url() string { return "http://127.0.0.1:" + strconv.Itoa(p.port) }
|
||||
|
||||
type envelope struct {
|
||||
|
||||
@@ -3,11 +3,14 @@ package nodee2e
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// TestNodeSync walks one master/node pair per enrollment scope through every
|
||||
@@ -91,6 +94,35 @@ func hasAll(have []string, want ...string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func clientIpSnapshot(t *testing.T, p *panel, email string) (string, map[string]int64) {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
ClientEmail string `json:"clientEmail"`
|
||||
Ips json.RawMessage `json:"ips"`
|
||||
}
|
||||
if err := json.Unmarshal(p.call(http.MethodGet, "/panel/api/server/clientIps", nil), &rows); err != nil {
|
||||
t.Fatalf("decode %s client IP rows: %v", p.name, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.ClientEmail != email {
|
||||
continue
|
||||
}
|
||||
var entries []model.ClientIpEntry
|
||||
if err := json.Unmarshal(row.Ips, &entries); err != nil {
|
||||
t.Fatalf("decode %s IP array: %v (%s)", p.name, err, row.Ips)
|
||||
}
|
||||
got := make(map[string]int64, len(entries))
|
||||
for _, entry := range entries {
|
||||
if _, duplicate := got[entry.IP]; duplicate {
|
||||
t.Fatalf("%s contains duplicate IP %s: %s", p.name, entry.IP, row.Ips)
|
||||
}
|
||||
got[entry.IP] = entry.Timestamp
|
||||
}
|
||||
return string(row.Ips), got
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func runNodeSyncScenarios(t *testing.T, bin, scope string) {
|
||||
master := newPanel(t, bin, "master")
|
||||
node := newPanel(t, bin, "node")
|
||||
@@ -243,6 +275,86 @@ func runNodeSyncScenarios(t *testing.T, bin, scope string) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("client IP union keeps maximum timestamps stable across sync and an offline node", func(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
post := func(p *panel, ips any) {
|
||||
p.call(http.MethodPost, "/panel/api/server/clientIps", []map[string]any{{"clientEmail": "p5", "ips": ips}})
|
||||
}
|
||||
post(master, []model.ClientIpEntry{
|
||||
{IP: "198.51.100.11", Timestamp: now - 30},
|
||||
{IP: "198.51.100.12", Timestamp: now - 20},
|
||||
})
|
||||
post(node, fmt.Sprintf(`[{"ip":"198.51.100.13","timestamp":%d},{"ip":"198.51.100.12","timestamp":%d}]`, now-10, now-5))
|
||||
want := map[string]int64{"198.51.100.11": now - 30, "198.51.100.12": now - 5, "198.51.100.13": now - 10}
|
||||
waitUnion := func() {
|
||||
t.Helper()
|
||||
eventually(t, settleTimeout, "both panels retain the exact IP union and maximum timestamps", func() (bool, string) {
|
||||
masterRaw, masterIPs := clientIpSnapshot(t, master, "p5")
|
||||
nodeRaw, nodeIPs := clientIpSnapshot(t, node, "p5")
|
||||
return maps.Equal(masterIPs, want) && maps.Equal(nodeIPs, want) && masterRaw == nodeRaw,
|
||||
fmt.Sprintf("master=%s node=%s want=%v", masterRaw, nodeRaw, want)
|
||||
})
|
||||
}
|
||||
waitUnion()
|
||||
stableRaw, _ := clientIpSnapshot(t, master, "p5")
|
||||
time.Sleep(12 * time.Second)
|
||||
for _, p := range []*panel{master, node} {
|
||||
raw, got := clientIpSnapshot(t, p, "p5")
|
||||
if raw != stableRaw || !maps.Equal(got, want) {
|
||||
t.Fatalf("%s IPs drifted after another sync cycle: %s, want %s", p.name, raw, stableRaw)
|
||||
}
|
||||
}
|
||||
node.stop()
|
||||
post(master, []model.ClientIpEntry{{IP: "198.51.100.14", Timestamp: now - 1}})
|
||||
node.start()
|
||||
want["198.51.100.14"] = now - 1
|
||||
waitUnion()
|
||||
})
|
||||
|
||||
t.Run("stopped database observations retain separate GUID attribution buckets", func(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
master.stop()
|
||||
node.stop()
|
||||
masterIPs := fmt.Sprintf(`[{"ip":"203.0.113.21","timestamp":%d}]`, now-20)
|
||||
nodeIPs := fmt.Sprintf(`[{"ip":"203.0.113.22","timestamp":%d}]`, now-10)
|
||||
masterGuid := master.seedClientIpAttribution("p5", masterIPs)
|
||||
nodeGuid := node.seedClientIpAttribution("p5", nodeIPs)
|
||||
if masterGuid == nodeGuid {
|
||||
t.Fatalf("independent panels share GUID %q", masterGuid)
|
||||
}
|
||||
master.start()
|
||||
node.start()
|
||||
want := map[string]map[string][]model.ClientIpEntry{
|
||||
masterGuid: {"p5": {{IP: "203.0.113.21", Timestamp: now - 20}}},
|
||||
nodeGuid: {"p5": {{IP: "203.0.113.22", Timestamp: now - 10}}},
|
||||
}
|
||||
eventually(t, settleTimeout, "master retains the exact independent GUID observation buckets", func() (bool, string) {
|
||||
var got map[string]map[string][]model.ClientIpEntry
|
||||
raw := master.call(http.MethodPost, "/panel/api/clients/clientIpsByGuid", nil)
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("decode attribution: %v", err)
|
||||
}
|
||||
expected, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(actual) == string(expected), fmt.Sprintf("attribution=%s want=%s", actual, expected)
|
||||
})
|
||||
var nodeTree map[string]map[string][]model.ClientIpEntry
|
||||
raw := node.call(http.MethodPost, "/panel/api/clients/clientIpsByGuid", nil)
|
||||
if err := json.Unmarshal(raw, &nodeTree); err != nil {
|
||||
t.Fatalf("decode node attribution: %v", err)
|
||||
}
|
||||
if len(nodeTree) != 1 || len(nodeTree[nodeGuid]) != 1 ||
|
||||
!slices.Equal(nodeTree[nodeGuid]["p5"], want[nodeGuid]["p5"]) {
|
||||
t.Fatalf("node attribution mixed parent observations into its own bucket: %s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an inbound deleted on the node is removed from the master too", func(t *testing.T) {
|
||||
pr.t = t
|
||||
master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("deleted-on-node", lostPort, &pr.nodeID, vlessClient("l1")))
|
||||
|
||||
Reference in New Issue
Block a user