Files
3x-ui/internal/web/service/client_identity_normalized_test.go
T
Sanaei f7db247b07 perf(clients): write client_inbounds deltas and check identity from the clients table
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
2026-08-20 04:09:57 +02:00

246 lines
9.2 KiB
Go

package service
import (
"strings"
"testing"
"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"
)
// Identity now comes from the clients table, so settings JSON that drifted from
// it no longer decides who may claim an email (#6252).
func TestAddInboundClientIgnoresStaleSettingsSubIds(t *testing.T) {
t.Run("stale entry no longer blocks the email", func(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
target := mkInbound(t, 21101, model.VLESS, `{"clients": []}`)
// Never synced, so no clients row backs it: pure settings-JSON drift.
mkInbound(t, 21102, model.VLESS, `{"clients": [{"email": "bob@x", "subId": "s-old", "enable": true}]}`)
add := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s-new"}}
if _, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)}); err != nil {
t.Fatalf("AddInboundClient rejected by a stale settings entry: %v", err)
}
if got := recordSubID(t, "bob@x"); got != "s-new" {
t.Errorf("stored subId = %q, want %q", got, "s-new")
}
})
t.Run("two drifted subIds no longer lock out the matching one", func(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
seed := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s1"}}
owner := mkInbound(t, 21103, model.VLESS, clientsSettings(t, seed))
if err := cs.SyncInbound(nil, owner.Id, seed); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
// A second inbound whose JSON disagrees about the subId. The old scan
// locked the email to "" and then rejected even the correct subId.
mkInbound(t, 21104, model.VLESS, `{"clients": [{"email": "bob@x", "subId": "s2", "enable": true}]}`)
target := mkInbound(t, 21105, model.VLESS, `{"clients": []}`)
add := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s1"}}
if _, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)}); err != nil {
t.Fatalf("AddInboundClient rejected the matching subId: %v", err)
}
})
}
// A mismatched subId must still be rejected: the check moved tables, it did not
// get weaker.
func TestAddInboundClientStillRejectsMismatchedSubId(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
seed := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s1"}}
owner := mkInbound(t, 21111, model.VLESS, clientsSettings(t, seed))
if err := cs.SyncInbound(nil, owner.Id, seed); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
target := mkInbound(t, 21112, model.VLESS, `{"clients": []}`)
add := []model.Client{{ID: "id-other", Email: "bob@x", Enable: true, SubID: "s-different"}}
_, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)})
if err == nil {
t.Fatal("a different subId for a taken email was accepted")
}
if !strings.Contains(err.Error(), "Duplicate email") {
t.Errorf("error = %q, want it to mention Duplicate email", err)
}
}
// emailsUsedByOtherInbounds keys on lower(email); the clients table stores the
// email as typed under a case-sensitive unique index, so a plain IN would miss.
func TestEmailsUsedByOtherInboundsMatchesCaseInsensitively(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
seed := []model.Client{{ID: "id-a", Email: "Alice@x", Enable: true, SubID: "s-a"}}
ibA := mkInbound(t, 21121, model.VLESS, clientsSettings(t, seed))
ibB := mkInbound(t, 21122, model.VLESS, clientsSettings(t, seed))
for _, ib := range []*model.Inbound{ibA, ibB} {
if err := cs.SyncInbound(nil, ib.Id, seed); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
}
shared, err := is.emailsUsedByOtherInbounds([]string{"alice@x"}, ibA.Id)
if err != nil {
t.Fatalf("emailsUsedByOtherInbounds: %v", err)
}
if !shared["alice@x"] {
t.Error("lower-cased lookup missed a stored mixed-case email")
}
used, err := is.emailUsedByOtherInbounds("alice@x", ibA.Id)
if err != nil {
t.Fatalf("emailUsedByOtherInbounds: %v", err)
}
if !used {
t.Error("emailUsedByOtherInbounds missed a stored mixed-case email")
}
// The traffic row is shared, so removing the client from one inbound keeps it.
if err := database.GetDB().Create(&xray.ClientTraffic{
InboundId: ibA.Id, Email: "Alice@x", Enable: true,
}).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
if _, err := cs.DelInboundClientByEmail(is, ibA.Id, "Alice@x", false, false); err != nil {
t.Fatalf("DelInboundClientByEmail: %v", err)
}
var count int64
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "Alice@x").Count(&count).Error; err != nil {
t.Fatalf("count traffic: %v", err)
}
if count == 0 {
t.Error("traffic row purged even though the email is still on another inbound")
}
}
// Guard, not a reproducer: this passes before the delta too. It pins the one
// delta case that is not obviously safe — the rename the taken-email guard
// refuses, where the old record must still lose this inbound's link.
func TestUpdateInboundClientRenameToTakenEmailDetachesOldLink(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
const subID = "s-shared"
oldSeed := []model.Client{{ID: "id-old", Email: "old@x", Enable: true, SubID: subID}}
newSeed := []model.Client{{ID: "id-new", Email: "new@x", Enable: true, SubID: subID}}
ibX := mkInbound(t, 21131, model.VLESS, clientsSettings(t, oldSeed))
ibY := mkInbound(t, 21132, model.VLESS, clientsSettings(t, newSeed))
if err := cs.SyncInbound(nil, ibX.Id, oldSeed); err != nil {
t.Fatalf("seed X: %v", err)
}
if err := cs.SyncInbound(nil, ibY.Id, newSeed); err != nil {
t.Fatalf("seed Y: %v", err)
}
renamed := []model.Client{{ID: "id-old", Email: "new@x", Enable: true, SubID: subID}}
if _, err := cs.UpdateInboundClient(is,
&model.Inbound{Id: ibX.Id, Settings: clientsSettings(t, renamed)}, "old@x"); err != nil {
t.Fatalf("UpdateInboundClient: %v", err)
}
links := linksOf(t, ibX.Id)
if len(links) != 1 {
t.Fatalf("inbound X link count = %d, want 1: %v", len(links), links)
}
if _, ok := links[recordID(t, "new@x")]; !ok {
t.Error("inbound X is not linked to the new@x record")
}
// The refused rename leaves old@x behind; it must not still claim inbound X.
if _, ok := links[recordID(t, "old@x")]; ok {
t.Error("old@x kept its link to inbound X after the rename")
}
}
func recordSubID(t *testing.T, email string) string {
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.SubID
}
// The stored record must carry the subId the panel generated into the settings
// JSON. Building the membership delta from the pre-stamp request values instead
// of the stamped wire entries silently desyncs the two.
func TestAddInboundClientPersistsTheGeneratedSubId(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
ib := mkInbound(t, 21141, model.VLESS, `{"clients": []}`)
add := []model.Client{{ID: "id-nosub", Email: "nosub@x", Enable: true}}
if _, err := cs.AddInboundClient(is, &model.Inbound{Id: ib.Id, Settings: clientsSettings(t, add)}); err != nil {
t.Fatalf("AddInboundClient: %v", err)
}
stored := recordSubID(t, "nosub@x")
if stored == "" {
t.Fatal("client record has no subId; the generated one was not persisted")
}
inSettings := settingsSubID(t, ib.Id, "nosub@x")
if stored != inSettings {
t.Errorf("record subId = %q but settings JSON says %q: the two representations desynced",
stored, inSettings)
}
}
// clients.email is unique but case-sensitive, so an identity check that does not
// fold case lets a second record for the same address be created.
func TestAddInboundClientRejectsCaseVariantOfTakenEmail(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
seed := []model.Client{{ID: "id-mix", Email: "Bob@x", Enable: true, SubID: "s-mix"}}
owner := mkInbound(t, 21151, model.VLESS, clientsSettings(t, seed))
if err := cs.SyncInbound(nil, owner.Id, seed); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
target := mkInbound(t, 21152, model.VLESS, `{"clients": []}`)
add := []model.Client{{ID: "id-other", Email: "bob@x", Enable: true, SubID: "s-other"}}
_, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)})
if err == nil {
var count int64
database.GetDB().Model(&model.ClientRecord{}).
Where("LOWER(email) = ?", "bob@x").Count(&count)
t.Fatalf("a case variant of a taken email was accepted; clients now holds %d rows for bob@x", count)
}
if !strings.Contains(err.Error(), "Duplicate email") {
t.Errorf("error = %q, want it to mention Duplicate email", err)
}
}
func settingsSubID(t *testing.T, inboundId int, email string) string {
t.Helper()
var ib model.Inbound
if err := database.GetDB().First(&ib, inboundId).Error; err != nil {
t.Fatalf("load inbound: %v", err)
}
clients, err := ParseInboundSettingsClients(ib.Settings)
if err != nil {
t.Fatalf("parse settings: %v", err)
}
for _, c := range clients {
if c.Email == email {
return c.SubID
}
}
t.Fatalf("%q not found in settings", email)
return ""
}