fix(node-sync): write only changed client rows in the node traffic merge

Every 5s node merge rewrote one client_traffics row and one
node_client_traffics baseline per (inbound, client) pair the node
reported, even when nothing had moved: a client is reported once per
inbound it is on, and idle clients every tick. On top of that each
inbound's SyncInbound wrote its own per-inbound values onto the shared
clients record, so a client on a flow and a non-flow inbound (or on a
WireGuard tunnel plus any other inbound) had its record flipped back
and forth twice per merge. With 7 nodes and ~20k client links that is
tens of thousands of statements per tick, all inside transactions on
the single serialized writer, holding row locks for seconds. A renew
of a client on 38 inbounds takes one writer turn per inbound, queued
behind those merges each time, and outlived the 30s HTTP write timeout
even though it committed; last_sub_fetch updates waited on the same
locks.

The merge now computes the row its UPDATE would leave, mirroring the
SQL merge expressions, and skips the write when nothing changes; the
baseline upsert is skipped when the node counter has not moved. The
record's per-inbound fields get one owner each, so repeated syncs
agree: flow is settled to the effective flow (lowest-id link carrying
one) for clients whose links changed, and WireGuard/AmneziaWG peer
fields come from the client's lowest-id tunnel. A repeated unchanged
snapshot now writes no client row; one active client writes two.

Verified beyond make verify: the CI Postgres lane, the merge and flow
tests run against PostgreSQL 16, and make node-e2e.

Closes #6815
This commit is contained in:
MHSanaei
2026-10-14 00:37:00 +02:00
parent 6f1dd0d757
commit f8b72de995
5 changed files with 389 additions and 83 deletions
@@ -324,3 +324,35 @@ func TestBulkAttach_PreservesVisionFlowWhenCanonicalColumnZeroed(t *testing.T) {
t.Errorf("bulk-attached non-flow inbound must not receive Vision flow, got %#v", wsList) t.Errorf("bulk-attached non-flow inbound must not receive Vision flow, got %#v", wsList)
} }
} }
// A client detached from its last inbound has no effective flow left to settle
// its record to, so the record keeps the flow a later attach falls back on.
func TestRecordFlow_DetachedFromLastInboundKeepsFlow(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 43001, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`}
if err := database.GetDB().Create(reality).Error; err != nil {
t.Fatalf("create reality inbound: %v", err)
}
svc := ClientService{}
const email = "detached@example.com"
const vision = "xtls-rprx-vision"
client := model.Client{Email: email, ID: "ce8d33df-3a64-4f10-8f9b-91c3a8e0c333", Enable: true, Flow: vision}
if err := svc.SyncInbound(nil, reality.Id, []model.Client{client}); err != nil {
t.Fatalf("SyncInbound(attach): %v", err)
}
if err := svc.SyncInbound(nil, reality.Id, nil); err != nil {
t.Fatalf("SyncInbound(detach): %v", err)
}
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
if rec.Flow != vision {
t.Errorf("record flow after detaching its last inbound = %q, want %q", rec.Flow, vision)
}
}
+114 -16
View File
@@ -105,7 +105,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
tx = database.GetDB() tx = database.GetDB()
} }
if err := s.validateProtocolClients(tx, inboundId, clients, detachEmails, prune); err != nil { var inbound model.Inbound
if err := tx.Select("protocol").Where("id = ?", inboundId).Take(&inbound).Error; err != nil {
return err
}
if err := s.validateProtocolClients(tx, inbound.Protocol, inboundId, clients, detachEmails, prune); err != nil {
return err return err
} }
@@ -137,6 +141,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
} }
} }
isTunnel := inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG
var onLowerTunnel map[int]struct{}
if isTunnel && len(existing) > 0 {
ids := make([]int, 0, len(existing))
for _, row := range existing {
ids = append(ids, row.Id)
}
var err error
if onLowerTunnel, err = clientsOnLowerTunnel(tx, inboundId, ids); err != nil {
return err
}
}
idByEmail := make(map[string]int, len(emails)) idByEmail := make(map[string]int, len(emails))
pending := make(map[string]*model.ClientRecord, len(emails)) pending := make(map[string]*model.ClientRecord, len(emails))
toCreate := make([]*model.ClientRecord, 0, len(emails)) toCreate := make([]*model.ClientRecord, 0, len(emails))
@@ -161,6 +178,8 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
before := *row before := *row
applyClientRecordMerge(row, incoming) applyClientRecordMerge(row, incoming)
_, outranked := onLowerTunnel[row.Id]
keepLinkOwnedFields(row, &before, isTunnel && !outranked)
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt) preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
row.UpdatedAt = preservedUpdatedAt row.UpdatedAt = preservedUpdatedAt
@@ -222,7 +241,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
wantedIds = append(wantedIds, id) wantedIds = append(wantedIds, id)
} }
if err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune); err != nil { relinked, err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune)
if err != nil {
return err
}
if err := settleRecordFlows(tx, relinked); err != nil {
return err return err
} }
@@ -260,19 +283,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
return nil return nil
} }
// reconcileInboundLinks writes only the client_inbounds rows that differ. prune // reconcileInboundLinks writes only the client_inbounds rows that differ and returns
// also removes links absent from wantedFlow, which only a full sync may do. // their clients. prune also removes links absent from wantedFlow, as only a full sync may.
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) error { func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) ([]int, error) {
var current []model.ClientInbound var current []model.ClientInbound
if prune { if prune {
if err := tx.Where("inbound_id = ?", inboundId).Find(&current).Error; err != nil { if err := tx.Where("inbound_id = ?", inboundId).Find(&current).Error; err != nil {
return err return nil, err
} }
} else { } else {
for _, batch := range chunkInts(wantedIds, sqlInChunk) { for _, batch := range chunkInts(wantedIds, sqlInChunk) {
var rows []model.ClientInbound var rows []model.ClientInbound
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil { if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
return err return nil, err
} }
current = append(current, rows...) current = append(current, rows...)
} }
@@ -300,7 +323,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
for _, batch := range chunkStrings(detachEmails, sqlInChunk) { for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
var ids []int var ids []int
if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil { if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
return err return nil, err
} }
for _, id := range ids { for _, id := range ids {
if _, keep := wantedFlow[id]; !keep { if _, keep := wantedFlow[id]; !keep {
@@ -325,7 +348,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
for _, batch := range chunkInts(toDelete, sqlInChunk) { for _, batch := range chunkInts(toDelete, sqlInChunk) {
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch). if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
Delete(&model.ClientInbound{}).Error; err != nil { Delete(&model.ClientInbound{}).Error; err != nil {
return err return nil, err
} }
} }
for flow, ids := range toUpdate { for flow, ids := range toUpdate {
@@ -333,7 +356,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
if err := tx.Model(&model.ClientInbound{}). if err := tx.Model(&model.ClientInbound{}).
Where("inbound_id = ? AND client_id IN ?", inboundId, batch). Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
Update("flow_override", flow).Error; err != nil { Update("flow_override", flow).Error; err != nil {
return err return nil, err
} }
} }
} }
@@ -344,8 +367,87 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
Columns: []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}}, Columns: []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
DoUpdates: clause.AssignmentColumns([]string{"flow_override"}), DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
}).CreateInBatches(toInsert, 200).Error; err != nil { }).CreateInBatches(toInsert, 200).Error; err != nil {
return nil, err
}
}
relinked := append([]int(nil), toDelete...)
for _, ids := range toUpdate {
relinked = append(relinked, ids...)
}
for _, link := range toInsert {
relinked = append(relinked, link.ClientId)
}
return relinked, nil
}
// keepLinkOwnedFields undoes the merge of the record fields one inbound cannot decide
// alone, or syncing a client's inbounds in turn rewrites the record back and forth.
func keepLinkOwnedFields(row, before *model.ClientRecord, ownsTunnelPeer bool) {
row.Flow = before.Flow
if ownsTunnelPeer {
return
}
row.PrivateKey = before.PrivateKey
row.PublicKey = before.PublicKey
row.AllowedIPs = before.AllowedIPs
row.PreSharedKey = before.PreSharedKey
row.KeepAlive = before.KeepAlive
}
// clientsOnLowerTunnel returns the clients also attached to a WireGuard/AmneziaWG
// inbound with a lower id than inboundId: that tunnel owns their record's peer fields.
func clientsOnLowerTunnel(tx *gorm.DB, inboundId int, clientIds []int) (map[int]struct{}, error) {
out := make(map[int]struct{})
for _, batch := range chunkInts(clientIds, sqlInChunk) {
var ids []int
if err := tx.Table("client_inbounds").
Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id").
Where("client_inbounds.client_id IN ? AND client_inbounds.inbound_id < ? AND inbounds.protocol IN ?",
batch, inboundId, []model.Protocol{model.WireGuard, model.AmneziaWG}).
Distinct().Pluck("client_inbounds.client_id", &ids).Error; err != nil {
return nil, err
}
for _, id := range ids {
out[id] = struct{}{}
}
}
return out, nil
}
// settleRecordFlows sets the record flow of each still-linked client to its
// effective flow (EffectiveFlow's rule); an unlinked record keeps its own.
func settleRecordFlows(tx *gorm.DB, clientIds []int) error {
for _, batch := range chunkInts(clientIds, sqlInChunk) {
var links []model.ClientInbound
if err := tx.Select("client_id", "flow_override").
Where("client_id IN ?", batch).
Order("inbound_id ASC").
Find(&links).Error; err != nil {
return err return err
} }
effective := make(map[int]string, len(batch))
for _, link := range links {
if flow, seen := effective[link.ClientId]; !seen || (flow == "" && link.FlowOverride != "") {
effective[link.ClientId] = link.FlowOverride
}
}
var records []model.ClientRecord
if err := tx.Select("id", "flow").Where("id IN ?", batch).Find(&records).Error; err != nil {
return err
}
idsByFlow := make(map[string][]int)
for _, rec := range records {
if flow, linked := effective[rec.Id]; linked && flow != rec.Flow {
idsByFlow[flow] = append(idsByFlow[flow], rec.Id)
}
}
for flow, ids := range idsByFlow {
if err := tx.Model(&model.ClientRecord{}).Where("id IN ?", ids).
UpdateColumn("flow", flow).Error; err != nil {
return err
}
}
} }
return nil return nil
} }
@@ -418,12 +520,8 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId
// validateProtocolClients runs inside the write every path linking clients to an inbound // validateProtocolClients runs inside the write every path linking clients to an inbound
// shares, so a client the protocol's core config would refuse never reaches it. // shares, so a client the protocol's core config would refuse never reaches it.
func (s *ClientService) validateProtocolClients(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error { func (s *ClientService) validateProtocolClients(tx *gorm.DB, protocol model.Protocol, inboundID int, changed []model.Client, detached []string, prune bool) error {
var inbound model.Inbound switch protocol {
if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
return err
}
switch inbound.Protocol {
case model.TUIC: case model.TUIC:
return s.validateTuicIdentities(tx, inboundID, changed, detached, prune) return s.validateTuicIdentities(tx, inboundID, changed, detached, prune)
case model.MASQUE: case model.MASQUE:
+76 -63
View File
@@ -224,6 +224,43 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error }).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
} }
// mergeNodeClientTraffic folds one node-reported client into its central row.
// While frozen (a push pending or just landed) only the counters may move.
func (s *InboundService) mergeNodeClientTraffic(tx *gorm.DB, cs xray.ClientTraffic, frozen bool, deltaUp, deltaDown, now int64) error {
if frozen {
return tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
SET up = %s, down = %s, last_online = %s
WHERE email = ?`,
database.ClampedAddExpr("up"),
database.ClampedAddExpr("down"),
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown, cs.LastOnline, cs.Email,
).Error
}
return tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
SET up = %s, down = %s, enable = %s, total = ?,
expiry_time = %s,
reset = ?, reset_day = ?, reset_weekday = ?, last_online = %s
WHERE email = ?`,
database.ClampedAddExpr("up"),
database.ClampedAddExpr("down"),
database.ClientTrafficEnableMergeExpr(),
database.ClientTrafficExpiryMergeExpr(),
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown,
cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
cs.Total,
cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
cs.LastOnline, cs.Email,
).Error
}
// mergeActivationExpiry: master absolute wins; node may only activate when // mergeActivationExpiry: master absolute wins; node may only activate when
// master is unset/duration. Node auto-renew goes through nodeClientRenewed. // master is unset/duration. Node auto-renew goes through nodeClientRenewed.
func mergeActivationExpiry(existing, node int64) int64 { func mergeActivationExpiry(existing, node int64) int64 {
@@ -634,22 +671,25 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
} }
} }
// Membership set for the rowExists checks below. Only the snapshot's emails // The stored row of every reported email, wherever it is homed, so the merge
// are ever probed, so scope the lookup to those instead of plucking the whole // can skip a write that would leave it unchanged. Homed rows share centralCS's pointer.
// client_traffics table (50k+ rows) on every node poll. statByEmail := make(map[string]*xray.ClientTraffic, len(snapEmailsAll))
existingEmails := make(map[string]struct{}, len(snapEmailsAll))
if len(snapEmailsAll) > 0 { if len(snapEmailsAll) > 0 {
snapEmailList := make([]string, 0, len(snapEmailsAll)) snapEmailList := make([]string, 0, len(snapEmailsAll))
for email := range snapEmailsAll { for email := range snapEmailsAll {
snapEmailList = append(snapEmailList, email) snapEmailList = append(snapEmailList, email)
} }
for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) { for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) {
var found []string var found []xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Pluck("email", &found).Error; err != nil { if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&found).Error; err != nil {
return false, err return false, err
} }
for _, e := range found { for i := range found {
existingEmails[e] = struct{}{} if homed, ok := centralCSByEmail[found[i].Email]; ok {
statByEmail[found[i].Email] = homed
continue
}
statByEmail[found[i].Email] = &found[i]
} }
} }
} }
@@ -1024,7 +1064,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
} }
} }
if _, rowExists := existingEmails[cs.Email]; !rowExists { if _, rowExists := statByEmail[cs.Email]; !rowExists {
if dirty { if dirty {
continue continue
} }
@@ -1062,7 +1102,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
} }
centralCS[csKey{c.Id, cs.Email}] = row centralCS[csKey{c.Id, cs.Email}] = row
centralCSByEmail[cs.Email] = row centralCSByEmail[cs.Email] = row
existingEmails[cs.Email] = struct{}{} statByEmail[cs.Email] = row
structuralChange = true structuralChange = true
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil { if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
return false, err return false, err
@@ -1121,64 +1161,34 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
existing.Total = cs.Total existing.Total = cs.Total
existing.ExpiryTime = cs.ExpiryTime existing.ExpiryTime = cs.ExpiryTime
existing.Reset = cs.Reset existing.Reset = cs.Reset
existing.ResetDay = cs.ResetDay
existing.ResetWeekday = cs.ResetWeekday existing.ResetWeekday = cs.ResetWeekday
existing.ResetCount = cs.ResetCount existing.ResetCount = cs.ResetCount
existing.LastOnline = max(existing.LastOnline, cs.LastOnline)
structuralChange = true structuralChange = true
} else if clientFrozen {
// Push pending or just landed: only counters may move, the master
// keeps expiry/enable/total/reset.
if err := tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
SET up = %s, down = %s, last_online = %s
WHERE email = ?`,
database.ClampedAddExpr("up"),
database.ClampedAddExpr("down"),
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown, cs.LastOnline, cs.Email,
).Error; err != nil {
return false, err
}
if existing != nil {
existing.Up = clampTrafficCounter(existing.Up + deltaUp)
existing.Down = clampTrafficCounter(existing.Down + deltaDown)
}
} else { } else {
enableExpr := database.ClientTrafficEnableMergeExpr() // The row as the UPDATE below would leave it, computed from its
expiryExpr := database.ClientTrafficExpiryMergeExpr() // pre-update values exactly as the SQL reads them.
if err := tx.Exec( row := statByEmail[cs.Email]
fmt.Sprintf( next := *row
`UPDATE client_traffics next.Up = clampTrafficCounter(row.Up + deltaUp)
SET up = %s, down = %s, enable = %s, total = ?, next.Down = clampTrafficCounter(row.Down + deltaDown)
expiry_time = %s, next.LastOnline = max(row.LastOnline, cs.LastOnline)
reset = ?, reset_day = ?, reset_weekday = ?, last_online = %s if !clientFrozen {
WHERE email = ?`, if !cs.Enable && !nodeDisableIsStale(row, cs, now, deltaUp, deltaDown) {
database.ClampedAddExpr("up"), next.Enable = false
database.ClampedAddExpr("down"),
enableExpr,
expiryExpr,
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown,
cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
cs.Total,
cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
cs.LastOnline, cs.Email,
).Error; err != nil {
return false, err
}
if existing != nil {
priorExpiry := existing.ExpiryTime
if !cs.Enable && !nodeDisableIsStale(existing, cs, now, deltaUp, deltaDown) {
existing.Enable = false
} }
existing.ExpiryTime = mergeActivationExpiry(priorExpiry, cs.ExpiryTime) next.ExpiryTime = mergeActivationExpiry(row.ExpiryTime, cs.ExpiryTime)
existing.Up = clampTrafficCounter(existing.Up + deltaUp) next.Total = cs.Total
existing.Down = clampTrafficCounter(existing.Down + deltaDown) next.Reset, next.ResetDay, next.ResetWeekday = cs.Reset, cs.ResetDay, cs.ResetWeekday
existing.Total = cs.Total }
existing.Reset = cs.Reset // A multi-attached client is reported once per inbound and an idle one
existing.ResetWeekday = cs.ResetWeekday // every tick: rewriting an unchanged row only holds its lock longer.
if next != *row {
if err := s.mergeNodeClientTraffic(tx, cs, clientFrozen, deltaUp, deltaDown, now); err != nil {
return false, err
}
*row = next
} }
} }
// A dip plus a lagging longer expiry mimics nodeClientRenewed and would // A dip plus a lagging longer expiry mimics nodeClientRenewed and would
@@ -1186,6 +1196,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if clientFrozen && seen && (canon.Up < base.Up || canon.Down < base.Down) { if clientFrozen && seen && (canon.Up < base.Up || canon.Down < base.Down) {
continue continue
} }
if seen && base == canon {
continue
}
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil { if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
return false, err return false, err
} }
@@ -1,9 +1,14 @@
package service package service
import ( import (
"encoding/json"
"fmt" "fmt"
"maps"
"path/filepath" "path/filepath"
"regexp"
"slices"
"strings" "strings"
"sync"
"testing" "testing"
"gorm.io/gorm" "gorm.io/gorm"
@@ -471,6 +476,163 @@ func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) {
assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment") assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment")
} }
var clientWriteSQL = regexp.MustCompile(`(?i)^\s*(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+["` + "`" + `]?(clients|client_inbounds|client_traffics|node_client_traffics)\b`)
// countClientRowWrites returns, per client table, how many rows fn's
// INSERT/UPDATE/DELETE statements touched.
func countClientRowWrites(t *testing.T, fn func()) map[string]int64 {
t.Helper()
db := database.GetDB()
var mu sync.Mutex
rows := map[string]int64{}
record := func(tx *gorm.DB) {
if tx.Error != nil || tx.RowsAffected == 0 || tx.Statement == nil {
return
}
if m := clientWriteSQL.FindStringSubmatch(tx.Statement.SQL.String()); m != nil {
mu.Lock()
rows[m[1]] += tx.RowsAffected
mu.Unlock()
}
}
const name = "test:count_client_row_writes"
cb := db.Callback()
for _, err := range []error{
cb.Create().After("gorm:create").Register(name, record),
cb.Update().After("gorm:update").Register(name, record),
cb.Delete().After("gorm:delete").Register(name, record),
cb.Raw().After("gorm:raw").Register(name, record),
} {
if err != nil {
t.Fatalf("register write callback: %v", err)
}
}
defer func() {
for _, err := range []error{
cb.Create().Remove(name), cb.Update().Remove(name),
cb.Delete().Remove(name), cb.Raw().Remove(name),
} {
if err != nil {
t.Errorf("remove write callback: %v", err)
}
}
}()
fn()
return rows
}
func TestNodeMerge_WritesOnlyChangedClients(t *testing.T) {
const clientCount = 20
type snapInbound struct {
tag string
protocol model.Protocol
entry func(i int, email string) map[string]any
}
vlessEntry := func(flow string) func(int, string) map[string]any {
return func(i int, email string) map[string]any {
e := map[string]any{"email": email, "id": fmt.Sprintf("%08d-0000-4000-8000-000000000000", i), "subId": email, "enable": true}
if flow != "" {
e["flow"] = flow
}
return e
}
}
wgEntry := func(tunnel int) func(int, string) map[string]any {
return func(i int, email string) map[string]any {
return map[string]any{
"email": email, "subId": email, "enable": true,
"privateKey": fmt.Sprintf("priv-%d-%d", tunnel, i),
"publicKey": fmt.Sprintf("pub-%d-%d", tunnel, i),
"allowedIPs": []string{fmt.Sprintf("10.%d.0.%d/32", tunnel, i+2)},
"preSharedKey": fmt.Sprintf("psk-%d-%d", tunnel, i),
"keepAlive": 25 * tunnel,
}
}
}
cases := []struct {
name string
inbounds []snapInbound
}{
{"vless with and without flow", []snapInbound{
{"n1-reality", model.VLESS, vlessEntry("xtls-rprx-vision")},
{"n1-ws", model.VLESS, vlessEntry("")},
}},
{"wireguard and vless", []snapInbound{
{"n1-wg", model.WireGuard, wgEntry(1)},
{"n1-vless", model.VLESS, vlessEntry("")},
}},
{"two wireguard tunnels", []snapInbound{
{"n1-wg-a", model.WireGuard, wgEntry(1)},
{"n1-wg-b", model.WireGuard, wgEntry(2)},
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
emails := make([]string, clientCount)
stats := make([]xray.ClientTraffic, clientCount)
for i := range emails {
emails[i] = fmt.Sprintf("multi-%d", i)
stats[i] = xray.ClientTraffic{Email: emails[i], Up: 1000, Down: 2000, Enable: true, LastOnline: 5000}
}
settings := make([]string, len(tc.inbounds))
for n, ib := range tc.inbounds {
entries := make([]map[string]any, clientCount)
for i, email := range emails {
entries[i] = ib.entry(i, email)
}
raw, err := json.Marshal(map[string]any{"clients": entries})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
settings[n] = string(raw)
nodeID := 1
if err := db.Create(&model.Inbound{
UserId: 1, NodeID: &nodeID, Tag: ib.tag, Enable: true,
Port: 42000 + n, Protocol: ib.protocol, Settings: settings[n],
}).Error; err != nil {
t.Fatalf("create inbound %q: %v", ib.tag, err)
}
}
merge := func(stats []xray.ClientTraffic) {
t.Helper()
// A node copies a multi-attached client's one counter onto every inbound it is on.
snap := &runtime.TrafficSnapshot{}
for n, ib := range tc.inbounds {
snap.Inbounds = append(snap.Inbounds, &model.Inbound{
Tag: ib.tag, Enable: true, Port: 42000 + n, Protocol: ib.protocol,
Settings: settings[n], ClientStats: slices.Clone(stats),
})
}
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
}
merge(stats)
if got := countClientRowWrites(t, func() { merge(stats) }); len(got) != 0 {
t.Errorf("repeating an unchanged snapshot wrote client rows %v, want none", got)
}
active := slices.Clone(stats)
active[0].Up, active[0].Down, active[0].LastOnline = 1300, 2600, 6000
got := countClientRowWrites(t, func() { merge(active) })
want := map[string]int64{"client_traffics": 1, "node_client_traffics": 1}
if !maps.Equal(got, want) {
t.Errorf("one client's traffic tick wrote client rows %v, want %v", got, want)
}
ct := readTraffic(t, db, emails[0])
assertUpDown(t, ct, 300, 600, "active client after its tick")
if ct.LastOnline != 6000 {
t.Errorf("last_online = %d, want 6000", ct.LastOnline)
}
})
}
}
func TestDelClientStat_CleansNodeBaselines(t *testing.T) { func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
db := initTrafficTestDB(t) db := initTrafficTestDB(t)
svc := &InboundService{} svc := &InboundService{}
@@ -94,12 +94,13 @@ func seedDualTunnelClient(t *testing.T, enabled bool, wgKeepAlive *int) string {
wgInbound := &model.Inbound{Tag: "wg-dual", Enable: true, Port: 51823, Protocol: model.WireGuard, Settings: string(wgSettings)} wgInbound := &model.Inbound{Tag: "wg-dual", Enable: true, Port: 51823, Protocol: model.WireGuard, Settings: string(wgSettings)}
awgInbound := &model.Inbound{Tag: "awg-dual", Enable: true, Port: 51824, Protocol: model.AmneziaWG, Settings: string(awgSettings)} awgInbound := &model.Inbound{Tag: "awg-dual", Enable: true, Port: 51824, Protocol: model.AmneziaWG, Settings: string(awgSettings)}
if err := db.Create(wgInbound).Error; err != nil { // The lower-id tunnel owns the shared record's peer fields: AmneziaWG first.
t.Fatalf("create wg inbound: %v", err)
}
if err := db.Create(awgInbound).Error; err != nil { if err := db.Create(awgInbound).Error; err != nil {
t.Fatalf("create awg inbound: %v", err) t.Fatalf("create awg inbound: %v", err)
} }
if err := db.Create(wgInbound).Error; err != nil {
t.Fatalf("create wg inbound: %v", err)
}
svc := ClientService{} svc := ClientService{}
if err := svc.SyncInbound(nil, wgInbound.Id, []model.Client{wgClient}); err != nil { if err := svc.SyncInbound(nil, wgInbound.Id, []model.Client{wgClient}); err != nil {
@@ -202,7 +203,7 @@ func TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields(t *testing.T) {
t.Fatalf("read shared client: %v", err) t.Fatalf("read shared client: %v", err)
} }
if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 { if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
t.Fatalf("test setup did not persist AmneziaWG last: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive) t.Fatalf("test setup did not persist AmneziaWG identity: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
} }
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual")) peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))