mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-21 10:27:14 +00:00
f7db247b07
Client CRUD latency scaled with the number of client-inbound edges rather than with the size of the change. On a 5k-client / 8-inbound / ~56k-edge PostgreSQL panel, creating one client took 60-120s (#6252). Two independent causes, both confirmed by the reporter's pg_stat_statements and reproduced locally at their topology. SyncInbound deleted every client_inbounds row for an inbound and re-inserted the whole set, so a one-client edit rewrote thousands of unrelated rows. The dominant caller was not user CRUD: the node traffic poll re-syncs every node inbound from its snapshot every 5s, so the panel churned the entire membership table continuously in the background. SyncInbound now reads the current links and writes only the difference - insert missing, update a changed flow_override, delete departed. Callers are unchanged, so every reconciliation path benefits, and the four hot client CRUD paths additionally pass only the clients they touched via ApplyInboundClientDelta. The insert needs clause.OnConflict: the unconditional delete it replaces also serialized concurrent syncs of one inbound, and the node poll commits in its own transaction outside the serialized writer, where a duplicate key would abort the whole poll on PostgreSQL. Identity and membership questions expanded every inbound's settings.clients JSON - 5.75s per call under the reporter's load. They now read the indexed clients and client_inbounds tables, which every read path already trusts, over just the emails being checked. A LOWER(email) expression index keeps the case-insensitive matching indexed; a struct tag cannot declare one. Measured on PostgreSQL 17 at 8 inbounds x 6000 clients, rows written to client_inbounds per operation, before -> after: create across 8 inbounds 48008 ins / 48000 del -> 8 ins / 0 del update the client 48008 ins / 48008 del -> 0 ins / 0 del detach from 4 inbounds 24000 ins / 24004 del -> 0 ins / 4 del delete the client 24000 ins / 24004 del -> 0 ins / 4 del Two behavior changes worth naming. An email seen with two different subIds across two inbounds' JSON used to be locked so that no add could claim it, including the one with the correct subId; the clients row now adjudicates. And on an install whose settings JSON holds an email with no matching link, "is this email on another inbound" now answers no, so deleting it elsewhere purges its traffic rows; compactOrphans and the startup heal already converge such drift. Every added test was verified against a hand-written mutation of this change, so none of them pass regardless of the fix. One mutation survives on purpose: swapping OnConflict DoUpdates for DoNothing is only observable when two transactions race the same row, and a timing-dependent test would be flaky. Per-node batching of remote pushes and the metadata-only inbounds list from the same report are deliberately not in this change. Closes #6252
210 lines
6.7 KiB
Go
210 lines
6.7 KiB
Go
package service
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// stampLinkCreatedAt marks every link of an inbound with a sentinel timestamp.
|
|
// A row that still carries it afterwards was not deleted and re-inserted.
|
|
func stampLinkCreatedAt(t *testing.T, inboundId int) {
|
|
t.Helper()
|
|
err := database.GetDB().Model(&model.ClientInbound{}).
|
|
Where("inbound_id = ?", inboundId).
|
|
UpdateColumn("created_at", 1).Error
|
|
if err != nil {
|
|
t.Fatalf("stamp created_at: %v", err)
|
|
}
|
|
}
|
|
|
|
func linksOf(t *testing.T, inboundId int) map[int]model.ClientInbound {
|
|
t.Helper()
|
|
var rows []model.ClientInbound
|
|
if err := database.GetDB().Where("inbound_id = ?", inboundId).Find(&rows).Error; err != nil {
|
|
t.Fatalf("load links: %v", err)
|
|
}
|
|
out := make(map[int]model.ClientInbound, len(rows))
|
|
for _, r := range rows {
|
|
out[r.ClientId] = r
|
|
}
|
|
return out
|
|
}
|
|
|
|
func recordID(t *testing.T, email string) int {
|
|
t.Helper()
|
|
var rec model.ClientRecord
|
|
if err := database.GetDB().Where("email = ?", email).First(&rec).Error; err != nil {
|
|
t.Fatalf("record %q: %v", email, err)
|
|
}
|
|
return rec.Id
|
|
}
|
|
|
|
// A re-sync that changes one client's flow must leave the other links in place
|
|
// and UPDATE the changed one, not rebuild the whole membership set (#6252).
|
|
func TestSyncInboundReusesUnchangedLinkRows(t *testing.T) {
|
|
setupBulkDB(t)
|
|
cs := &ClientService{}
|
|
|
|
seed := []model.Client{
|
|
{ID: "id-a", Email: "a@x", Enable: true, SubID: "s-a"},
|
|
{ID: "id-b", Email: "b@x", Enable: true, SubID: "s-b", Flow: "xtls-rprx-vision"},
|
|
{ID: "id-c", Email: "c@x", Enable: true, SubID: "s-c"},
|
|
}
|
|
ib := mkInbound(t, 21001, model.VLESS, clientsSettings(t, seed))
|
|
if err := cs.SyncInbound(nil, ib.Id, seed); err != nil {
|
|
t.Fatalf("seed SyncInbound: %v", err)
|
|
}
|
|
stampLinkCreatedAt(t, ib.Id)
|
|
|
|
changed := make([]model.Client, len(seed))
|
|
copy(changed, seed)
|
|
changed[1].Flow = ""
|
|
if err := cs.SyncInbound(nil, ib.Id, changed); err != nil {
|
|
t.Fatalf("re-sync: %v", err)
|
|
}
|
|
|
|
links := linksOf(t, ib.Id)
|
|
if len(links) != 3 {
|
|
t.Fatalf("link count = %d, want 3", len(links))
|
|
}
|
|
for _, email := range []string{"a@x", "b@x", "c@x"} {
|
|
link, ok := links[recordID(t, email)]
|
|
if !ok {
|
|
t.Fatalf("%s lost its link", email)
|
|
}
|
|
if link.CreatedAt != 1 {
|
|
t.Errorf("%s link created_at = %d, want the 1 sentinel: the row was deleted and re-inserted", email, link.CreatedAt)
|
|
}
|
|
}
|
|
if got := links[recordID(t, "b@x")].FlowOverride; got != "" {
|
|
t.Errorf("b@x flow_override = %q, want \"\" (cleared in place)", got)
|
|
}
|
|
|
|
// Dropping a client must still remove exactly that one link.
|
|
if err := cs.SyncInbound(nil, ib.Id, []model.Client{seed[0], seed[2]}); err != nil {
|
|
t.Fatalf("prune sync: %v", err)
|
|
}
|
|
links = linksOf(t, ib.Id)
|
|
if len(links) != 2 {
|
|
t.Fatalf("after prune link count = %d, want 2", len(links))
|
|
}
|
|
if _, still := links[recordID(t, "b@x")]; still {
|
|
t.Error("b@x link survived a full sync that dropped it")
|
|
}
|
|
for _, email := range []string{"a@x", "c@x"} {
|
|
if links[recordID(t, email)].CreatedAt != 1 {
|
|
t.Errorf("%s link was rebuilt by the prune sync", email)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Adding a client must not re-merge its bystanders' records from the settings
|
|
// JSON; comment lives only in the clients table, so a full sync erases it.
|
|
func TestAddInboundClientLeavesBystanderRecordsUntouched(t *testing.T) {
|
|
setupBulkDB(t)
|
|
cs := &ClientService{}
|
|
is := &InboundService{}
|
|
|
|
seed := []model.Client{
|
|
{ID: "id-a", Email: "a@x", Enable: true, SubID: "s-a"},
|
|
{ID: "id-b", Email: "b@x", Enable: true, SubID: "s-b"},
|
|
}
|
|
ib := mkInbound(t, 21002, model.VLESS, clientsSettings(t, seed))
|
|
if err := cs.SyncInbound(nil, ib.Id, seed); err != nil {
|
|
t.Fatalf("seed SyncInbound: %v", err)
|
|
}
|
|
db := database.GetDB()
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "a@x").
|
|
UpdateColumn("comment", "operator note").Error; err != nil {
|
|
t.Fatalf("set comment: %v", err)
|
|
}
|
|
stampLinkCreatedAt(t, ib.Id)
|
|
|
|
add := []model.Client{{ID: "id-c", Email: "c@x", Enable: true, SubID: "s-c"}}
|
|
if _, err := cs.AddInboundClient(is, &model.Inbound{Id: ib.Id, Settings: clientsSettings(t, add)}); err != nil {
|
|
t.Fatalf("AddInboundClient: %v", err)
|
|
}
|
|
|
|
var bystander model.ClientRecord
|
|
if err := db.Where("email = ?", "a@x").First(&bystander).Error; err != nil {
|
|
t.Fatalf("reload a@x: %v", err)
|
|
}
|
|
if bystander.Comment != "operator note" {
|
|
t.Errorf("bystander comment = %q, want %q: the add re-merged an unrelated record from settings JSON",
|
|
bystander.Comment, "operator note")
|
|
}
|
|
|
|
links := linksOf(t, ib.Id)
|
|
if len(links) != 3 {
|
|
t.Fatalf("link count = %d, want 3", len(links))
|
|
}
|
|
for _, email := range []string{"a@x", "b@x"} {
|
|
if links[recordID(t, email)].CreatedAt != 1 {
|
|
t.Errorf("%s link was rebuilt by an unrelated add", email)
|
|
}
|
|
}
|
|
newLink, ok := links[recordID(t, "c@x")]
|
|
if !ok {
|
|
t.Fatal("c@x got no link")
|
|
}
|
|
if newLink.CreatedAt == 1 {
|
|
t.Error("c@x link carries the sentinel; it should be freshly inserted")
|
|
}
|
|
}
|
|
|
|
// Deleting one client detaches only that client; the others keep both their
|
|
// link rows and the record fields that live only in the clients table.
|
|
func TestDelInboundClientDetachesOnlyTheRemovedClient(t *testing.T) {
|
|
setupBulkDB(t)
|
|
cs := &ClientService{}
|
|
is := &InboundService{}
|
|
|
|
seed := []model.Client{
|
|
{ID: "id-a", Email: "a@x", Enable: true, SubID: "s-a"},
|
|
{ID: "id-b", Email: "b@x", Enable: true, SubID: "s-b"},
|
|
{ID: "id-c", Email: "c@x", Enable: true, SubID: "s-c"},
|
|
}
|
|
ib := mkInbound(t, 21003, model.VLESS, clientsSettings(t, seed))
|
|
if err := cs.SyncInbound(nil, ib.Id, seed); err != nil {
|
|
t.Fatalf("seed SyncInbound: %v", err)
|
|
}
|
|
db := database.GetDB()
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "c@x").
|
|
UpdateColumn("comment", "keep me").Error; err != nil {
|
|
t.Fatalf("set comment: %v", err)
|
|
}
|
|
removedID := recordID(t, "b@x")
|
|
stampLinkCreatedAt(t, ib.Id)
|
|
|
|
if _, err := cs.DelInboundClientByEmail(is, ib.Id, "b@x", true, false); err != nil {
|
|
t.Fatalf("DelInboundClientByEmail: %v", err)
|
|
}
|
|
|
|
links := linksOf(t, ib.Id)
|
|
if _, still := links[removedID]; still {
|
|
t.Error("b@x link survived the delete")
|
|
}
|
|
if len(links) != 2 {
|
|
t.Fatalf("link count = %d, want 2", len(links))
|
|
}
|
|
for _, email := range []string{"a@x", "c@x"} {
|
|
if links[recordID(t, email)].CreatedAt != 1 {
|
|
t.Errorf("%s link was rebuilt by an unrelated delete", email)
|
|
}
|
|
}
|
|
// Detach must not delete the record itself.
|
|
var removed model.ClientRecord
|
|
if err := db.Where("email = ?", "b@x").First(&removed).Error; err != nil {
|
|
t.Fatalf("b@x record should survive a detach: %v", err)
|
|
}
|
|
var kept model.ClientRecord
|
|
if err := db.Where("email = ?", "c@x").First(&kept).Error; err != nil {
|
|
t.Fatalf("reload c@x: %v", err)
|
|
}
|
|
if kept.Comment != "keep me" {
|
|
t.Errorf("bystander comment = %q, want %q", kept.Comment, "keep me")
|
|
}
|
|
}
|