Files
3x-ui/internal/web/service/global_traffic_heartbeat_test.go
T
Hamed 64a7697f56 perf(traffic): skip unchanged global writes between heartbeats (#6931)
* 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
2026-10-25 08:44:05 +01:00

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)
}