mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-29 06:31:58 +00:00
* perf(traffic): skip unchanged global writes between heartbeats Persist directional usage changes immediately, including lower resets, while refreshing identical global totals only at an inclusive one-hour heartbeat. Use the GORM clock consistently for the existing 24-hour freshness window and preserve local-only snapshots, quota enforcement, and downstream forwarding. Fixes #6928 * docs(traffic): describe persisted global heartbeat timestamps Replace the stale every-push model comment with the change-or-hourly-heartbeat rule. Keep the local-counter separation, delta accounting, MAX fold and reset semantics explicit without treating UpdatedAt as a last-push timestamp. Address the nonblocking review feedback on PR #6931 without rewriting published history. Refs #6928
375 lines
13 KiB
Go
375 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
func globalTrafficClock(t *testing.T, db *gorm.DB, queued bool) *atomic.Int64 {
|
|
t.Helper()
|
|
StopTrafficWriter()
|
|
old := db.NowFunc
|
|
clock := &atomic.Int64{}
|
|
clock.Store(time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC).UnixMilli())
|
|
db.NowFunc = func() time.Time { return time.UnixMilli(clock.Load()) }
|
|
t.Cleanup(func() {
|
|
StopTrafficWriter()
|
|
db.NowFunc = old
|
|
})
|
|
if queued {
|
|
StartTrafficWriter()
|
|
}
|
|
return clock
|
|
}
|
|
|
|
func countGlobalTrafficWrites(t *testing.T, db *gorm.DB) *atomic.Int64 {
|
|
t.Helper()
|
|
rows := &atomic.Int64{}
|
|
const name = "test:global_traffic_affected_rows"
|
|
if err := db.Callback().Create().After("gorm:create").Register(name, func(tx *gorm.DB) {
|
|
if tx.Error == nil && tx.Statement.Table == "client_global_traffics" {
|
|
rows.Add(tx.RowsAffected)
|
|
}
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := db.Callback().Create().Remove(name); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
return rows
|
|
}
|
|
|
|
func pushGlobalTraffic(t *testing.T, guid string, rows ...*xray.ClientTraffic) {
|
|
t.Helper()
|
|
if err := (&InboundService{}).AcceptGlobalTraffic(guid, rows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func globalTrafficRow(t *testing.T, db *gorm.DB, guid, email string) model.ClientGlobalTraffic {
|
|
t.Helper()
|
|
var row model.ClientGlobalTraffic
|
|
if err := db.Where("master_guid = ? AND email = ?", guid, email).First(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return row
|
|
}
|
|
|
|
func TestGlobalTrafficHeartbeatBounds(t *testing.T) {
|
|
db := initGlobalHeartbeatDB(t)
|
|
clock := globalTrafficClock(t, db, true)
|
|
seedClientRow(t, "bounds", 1, 10, 20, 0)
|
|
writes := countGlobalTrafficWrites(t, db)
|
|
start := clock.Load()
|
|
push := func(up, down, wantWrites int64) {
|
|
t.Helper()
|
|
writes.Store(0)
|
|
pushGlobalTraffic(t, "master", &xray.ClientTraffic{Email: "bounds", Up: up, Down: down})
|
|
if got := writes.Load(); got != wantWrites {
|
|
t.Fatalf("at %d counters %d/%d: affected rows=%d want %d", clock.Load()-start, up, down, got, wantWrites)
|
|
}
|
|
got := globalTrafficRow(t, db, "master", "bounds")
|
|
if got.Up != up || got.Down != down {
|
|
t.Fatalf("counters=%d/%d want %d/%d", got.Up, got.Down, up, down)
|
|
}
|
|
}
|
|
push(100, 200, 1)
|
|
clock.Store(start + time.Hour.Milliseconds() - 1)
|
|
push(100, 200, 0)
|
|
if got := globalTrafficRow(t, db, "master", "bounds").UpdatedAt; got != start {
|
|
t.Fatalf("early heartbeat timestamp=%d want %d", got, start)
|
|
}
|
|
clock.Add(1)
|
|
push(100, 200, 1)
|
|
if got := globalTrafficRow(t, db, "master", "bounds").UpdatedAt; got != clock.Load() {
|
|
t.Fatalf("boundary timestamp=%d want %d", got, clock.Load())
|
|
}
|
|
clock.Add(1)
|
|
push(100, 200, 0)
|
|
for _, pair := range [][2]int64{{101, 200}, {99, 200}, {99, 201}, {99, 199}, {0, 199}, {0, 0}} {
|
|
push(pair[0], pair[1], 1)
|
|
}
|
|
local := readTraffic(t, db, "bounds")
|
|
if local.Up != 10 || local.Down != 20 {
|
|
t.Fatalf("local traffic changed: %+v", local)
|
|
}
|
|
}
|
|
|
|
func TestGlobalTrafficHeartbeatNullableAndBatch(t *testing.T) {
|
|
db := initGlobalHeartbeatDB(t)
|
|
clock := globalTrafficClock(t, db, true)
|
|
var payload []*xray.ClientTraffic
|
|
for i := range 405 {
|
|
email := fmt.Sprintf("batch-%03d", i)
|
|
seedClientRow(t, email, 1, 0, 0, 0)
|
|
payload = append(payload, &xray.ClientTraffic{Email: email, Up: 10, Down: 20})
|
|
}
|
|
pushGlobalTraffic(t, "master", payload[:404]...)
|
|
clock.Add(time.Minute.Milliseconds())
|
|
for i, column := range []string{"up", "down", "updated_at"} {
|
|
if err := db.Model(&model.ClientGlobalTraffic{}).Where("email = ?", payload[i].Email).UpdateColumn(column, nil).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := db.Model(&model.ClientGlobalTraffic{}).Where("email = ?", payload[3].Email).UpdateColumn("updated_at", clock.Load()-time.Hour.Milliseconds()).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payload[4].Up = 11
|
|
payload = append(payload, nil, &xray.ClientTraffic{Email: "ghost", Up: 999}, &xray.ClientTraffic{Email: payload[5].Email, Up: 12, Down: 23})
|
|
writes := countGlobalTrafficWrites(t, db)
|
|
pushGlobalTraffic(t, "master", payload...)
|
|
if got := writes.Load(); got != 7 {
|
|
t.Fatalf("mixed two-full-batch payload affected %d rows want 7", got)
|
|
}
|
|
for i := range 6 {
|
|
got := globalTrafficRow(t, db, "master", payload[i].Email)
|
|
up, down := payload[i].Up, payload[i].Down
|
|
if i == 5 {
|
|
up, down = 12, 23
|
|
}
|
|
if got.Up != up || got.Down != down || got.UpdatedAt != clock.Load() {
|
|
t.Fatalf("row %d=%+v want %d/%d at %d", i, got, up, down, clock.Load())
|
|
}
|
|
}
|
|
var count int64
|
|
if err := db.Model(&model.ClientGlobalTraffic{}).Count(&count).Error; err != nil || count != 405 {
|
|
t.Fatalf("known rows=%d want 405 err=%v", count, err)
|
|
}
|
|
}
|
|
|
|
func TestGlobalTrafficHeartbeatLongIdleAndRecovery(t *testing.T) {
|
|
db := initGlobalHeartbeatDB(t)
|
|
clock := globalTrafficClock(t, db, true)
|
|
seedClientRow(t, "active", 1, 1, 2, 100)
|
|
seedClientRow(t, "absent", 1, 1, 2, 100)
|
|
ib := &model.Inbound{UserId: 1, Tag: "idle", Port: 43124, Enable: true, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := (&ClientService{}).SyncInbound(db, ib.Id, []model.Client{
|
|
{Email: "active", Enable: true, TotalGB: 100}, {Email: "absent", Enable: true, TotalGB: 100},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
start := clock.Load()
|
|
pushGlobalTraffic(t, "active-master", &xray.ClientTraffic{Email: "active", Up: 80, Down: 70})
|
|
pushGlobalTraffic(t, "absent-master", &xray.ClientTraffic{Email: "absent", Up: 80, Down: 70})
|
|
writes := countGlobalTrafficWrites(t, db)
|
|
for tick := 1; tick <= 5760; tick++ {
|
|
clock.Store(start + int64(tick)*30_000)
|
|
pushGlobalTraffic(t, "active-master", &xray.ClientTraffic{Email: "active", Up: 80, Down: 70})
|
|
}
|
|
if got := writes.Load(); got != 48 {
|
|
t.Fatalf("48h idle pushes affected %d rows want 48", got)
|
|
}
|
|
assertUsage := func(email string, up, down int64, depleted bool) {
|
|
t.Helper()
|
|
rows := []*xray.ClientTraffic{{Email: email, Up: 1, Down: 2}}
|
|
overlayGlobalTraffic(db, rows)
|
|
if rows[0].Up != up || rows[0].Down != down {
|
|
t.Fatalf("%s overlay=%d/%d want %d/%d", email, rows[0].Up, rows[0].Down, up, down)
|
|
}
|
|
cond, args := depletedCond(db)
|
|
var count int64
|
|
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Where(cond, args...).Count(&count).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if (count == 1) != depleted {
|
|
t.Fatalf("%s depleted count=%d want depleted=%v", email, count, depleted)
|
|
}
|
|
page, err := (&ClientService{}).ListPaged(&InboundService{}, &SettingService{}, ClientPageParams{PageSize: 10, Search: email, Filter: "depleted"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := 0
|
|
if depleted {
|
|
want = 1
|
|
}
|
|
if len(page.Items) != want || (want == 1 && page.Items[0].Email != email) {
|
|
t.Fatalf("%s depleted page=%+v want %d rows", email, page.Items, want)
|
|
}
|
|
}
|
|
assertUsage("active", 80, 70, true)
|
|
assertUsage("absent", 1, 2, false)
|
|
last := clock.Load()
|
|
clock.Add(23 * time.Hour.Milliseconds())
|
|
assertUsage("active", 80, 70, true)
|
|
clock.Store(last + 24*time.Hour.Milliseconds())
|
|
assertUsage("active", 80, 70, true)
|
|
clock.Add(1)
|
|
assertUsage("active", 1, 2, false)
|
|
pushGlobalTraffic(t, "active-master", &xray.ClientTraffic{Email: "active", Up: 80, Down: 70})
|
|
assertUsage("active", 80, 70, true)
|
|
}
|
|
|
|
func TestGlobalTrafficHeartbeatConcurrentPairs(t *testing.T) {
|
|
for _, queued := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("queued=%v", queued), func(t *testing.T) {
|
|
db := initGlobalHeartbeatDB(t)
|
|
globalTrafficClock(t, db, queued)
|
|
seedClientRow(t, "concurrent", 1, 1, 2, 0)
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 24)
|
|
start := make(chan struct{})
|
|
for i := range 24 {
|
|
wg.Go(func() {
|
|
<-start
|
|
guid := "same"
|
|
if i >= 12 {
|
|
guid = fmt.Sprintf("master-%d", i)
|
|
}
|
|
errs <- (&InboundService{}).AcceptGlobalTraffic(guid, []*xray.ClientTraffic{{Email: "concurrent", Up: int64(i + 100), Down: int64(1000 - i)}})
|
|
})
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
row := globalTrafficRow(t, db, "same", "concurrent")
|
|
if row.Up < 100 || row.Up > 111 || row.Up+row.Down != 1100 {
|
|
t.Fatalf("torn submitted pair: %+v", row)
|
|
}
|
|
for i := 12; i < 24; i++ {
|
|
got := globalTrafficRow(t, db, fmt.Sprintf("master-%d", i), "concurrent")
|
|
if got.Up != int64(i+100) || got.Down != int64(1000-i) {
|
|
t.Fatalf("master %d pair=%+v", i, got)
|
|
}
|
|
}
|
|
pushGlobalTraffic(t, "same", &xray.ClientTraffic{Email: "concurrent", Up: 3, Down: 4})
|
|
row = globalTrafficRow(t, db, "same", "concurrent")
|
|
if row.Up != 3 || row.Down != 4 {
|
|
t.Fatalf("ordered lower reset=%+v", row)
|
|
}
|
|
rows := []*xray.ClientTraffic{{Email: "concurrent", Up: 1, Down: 2}}
|
|
overlayGlobalTraffic(db, rows)
|
|
if rows[0].Up != 123 || rows[0].Down != 988 {
|
|
t.Fatalf("componentwise max=%d/%d want 123/988", rows[0].Up, rows[0].Down)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGlobalTrafficHeartbeatQuotaAndLifecycle(t *testing.T) {
|
|
db := initGlobalHeartbeatDB(t)
|
|
globalTrafficClock(t, db, true)
|
|
svc := &InboundService{}
|
|
ib := &model.Inbound{
|
|
UserId: 1, Tag: "quota", Port: 43123, Enable: true, Protocol: model.VLESS,
|
|
Settings: `{"clients":[{"email":"quota","enable":true,"totalGB":1000}]}`,
|
|
}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clients := &ClientService{}
|
|
if err := clients.SyncInbound(db, ib.Id, []model.Client{{Email: "quota", Enable: true, TotalGB: 1000}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedClientRow(t, "quota", ib.Id, 10, 20, 1000)
|
|
invalidations := &atomic.Int64{}
|
|
release := RegisterSubDataInvalidator(func() { invalidations.Add(1) })
|
|
defer release()
|
|
pushGlobalTraffic(t, "master", &xray.ClientTraffic{Email: "quota", Up: 800, Down: 700})
|
|
pushGlobalTraffic(t, "master", &xray.ClientTraffic{Email: "quota", Up: 800, Down: 700})
|
|
if invalidations.Load() != 0 {
|
|
t.Fatalf("ingestion invalidated subscriptions %d times", invalidations.Load())
|
|
}
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
_, count, _, err := svc.disableInvalidClients(tx, newTrafficMutationBatch())
|
|
if count != 1 {
|
|
t.Errorf("disabled rows=%d want 1", count)
|
|
}
|
|
return err
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if invalidations.Load() != 1 {
|
|
t.Fatalf("quota disable invalidations=%d want 1", invalidations.Load())
|
|
}
|
|
if err := db.First(ib, ib.Id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var settings struct {
|
|
Clients []struct {
|
|
Email string `json:"email"`
|
|
Enable bool `json:"enable"`
|
|
} `json:"clients"`
|
|
}
|
|
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(settings.Clients) != 1 || settings.Clients[0].Email != "quota" || settings.Clients[0].Enable || readTraffic(t, db, "quota").Enable {
|
|
t.Fatalf("disabled settings=%s traffic=%+v", ib.Settings, readTraffic(t, db, "quota"))
|
|
}
|
|
if err := svc.ResetClientTrafficByEmail("quota"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertRows := func(want int64) {
|
|
t.Helper()
|
|
var count int64
|
|
if err := db.Model(&model.ClientGlobalTraffic{}).Where("email = ?", "quota").Count(&count).Error; err != nil || count != want {
|
|
t.Fatalf("side rows=%d want %d err=%v", count, want, err)
|
|
}
|
|
}
|
|
assertRows(0)
|
|
pushGlobalTraffic(t, "master", &xray.ClientTraffic{Email: "quota", Up: 0, Down: 0})
|
|
assertRows(1)
|
|
if _, err := clients.DeleteByEmail(svc, "quota", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertRows(0)
|
|
pushGlobalTraffic(t, "master", &xray.ClientTraffic{Email: "quota", Up: 7, Down: 8})
|
|
assertRows(0)
|
|
if _, err := clients.CreateOne(svc, ib.Id, model.Client{Email: "quota", Enable: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pushGlobalTraffic(t, "master", &xray.ClientTraffic{Email: "quota", Up: 7, Down: 8})
|
|
assertRows(1)
|
|
got := globalTrafficRow(t, db, "master", "quota")
|
|
if got.Up != 7 || got.Down != 8 {
|
|
t.Fatalf("recreated counters=%+v", got)
|
|
}
|
|
}
|
|
|
|
func TestGlobalTrafficHeartbeatForwarding(t *testing.T) {
|
|
db := initGlobalHeartbeatDB(t)
|
|
clock := globalTrafficClock(t, db, true)
|
|
seedClientRow(t, "forward", 1, 10, 20, 0)
|
|
if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "forward"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertForward := func(up, down int64) {
|
|
t.Helper()
|
|
rows, err := (&InboundService{}).GetNodeClientTraffics(7)
|
|
if err != nil || len(rows) != 1 || rows[0].Up != up || rows[0].Down != down {
|
|
t.Fatalf("forward=%+v want %d/%d err=%v", rows, up, down, err)
|
|
}
|
|
}
|
|
pushGlobalTraffic(t, "upstream", &xray.ClientTraffic{Email: "forward", Up: 900, Down: 800})
|
|
writes := countGlobalTrafficWrites(t, db)
|
|
clock.Add(time.Minute.Milliseconds())
|
|
pushGlobalTraffic(t, "upstream", &xray.ClientTraffic{Email: "forward", Up: 900, Down: 800})
|
|
assertForward(900, 800)
|
|
if writes.Load() != 0 {
|
|
t.Fatalf("unchanged forward push affected %d rows", writes.Load())
|
|
}
|
|
pushGlobalTraffic(t, "upstream", &xray.ClientTraffic{Email: "forward", Up: 50, Down: 60})
|
|
assertForward(50, 60)
|
|
pushGlobalTraffic(t, "upstream", &xray.ClientTraffic{Email: "forward", Up: 0, Down: 0})
|
|
assertForward(10, 20)
|
|
}
|