mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-26 15:55:02 +03:00
8eb58a4a14
The access-log scanner and the cross-node merge stored the same IP list in opposite orders, so neither saw a row as unchanged, and the merge re-read every preloaded row and re-upserted every attribution row each sync cycle. All writers now share one canonical encoder (timestamp, then IP ascending), the first CAS is seeded from the preload, and identical attribution rows are skipped. A create race between the scanner and node sync now merges into the winning row instead of dropping the loser's IPs, which on PostgreSQL also aborted the scanner's whole scan transaction. Closes #6927
207 lines
7.2 KiB
Go
207 lines
7.2 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
|
|
)
|
|
|
|
type clientIpPGStats struct {
|
|
Calls float64
|
|
Rows float64
|
|
TotalExecTime float64
|
|
WalRecords float64
|
|
WalFpi float64
|
|
WalBytes float64
|
|
SharedBlksHit float64
|
|
SharedBlksRead float64
|
|
SharedBlksDirtied float64
|
|
}
|
|
|
|
func clientIpPGSnapshot(t *testing.T) clientIpPGStats {
|
|
t.Helper()
|
|
var out clientIpPGStats
|
|
db := database.GetDB()
|
|
if db.Name() != "postgres" {
|
|
return out
|
|
}
|
|
err := db.Raw(`SELECT COALESCE(sum(calls),0) AS calls, COALESCE(sum(rows),0) AS rows,
|
|
COALESCE(sum(total_exec_time),0) AS total_exec_time, COALESCE(sum(wal_records),0) AS wal_records,
|
|
COALESCE(sum(wal_fpi),0) AS wal_fpi, COALESCE(sum(wal_bytes),0) AS wal_bytes,
|
|
COALESCE(sum(shared_blks_hit),0) AS shared_blks_hit, COALESCE(sum(shared_blks_read),0) AS shared_blks_read,
|
|
COALESCE(sum(shared_blks_dirtied),0) AS shared_blks_dirtied
|
|
FROM public.pg_stat_statements WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
AND (query ILIKE '%inbound_client_ips%' OR query ILIKE '%node_client_ips%')
|
|
AND query NOT ILIKE '%pg_stat_statements%'`).Scan(&out).Error
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func logClientIpPGDelta(t *testing.T, before, after clientIpPGStats) {
|
|
t.Helper()
|
|
if database.GetDB().Name() != "postgres" {
|
|
return
|
|
}
|
|
t.Logf("PG delta calls=%.0f rows=%.0f total_exec_time_ms=%.3f wal_records=%.0f wal_fpi=%.0f wal_bytes=%.0f shared_blks_hit=%.0f read=%.0f dirtied=%.0f",
|
|
after.Calls-before.Calls, after.Rows-before.Rows, after.TotalExecTime-before.TotalExecTime,
|
|
after.WalRecords-before.WalRecords, after.WalFpi-before.WalFpi, after.WalBytes-before.WalBytes,
|
|
after.SharedBlksHit-before.SharedBlksHit, after.SharedBlksRead-before.SharedBlksRead, after.SharedBlksDirtied-before.SharedBlksDirtied)
|
|
}
|
|
|
|
func logClientIpPGContext(t *testing.T) {
|
|
t.Helper()
|
|
db := database.GetDB()
|
|
if db.Name() != "postgres" {
|
|
return
|
|
}
|
|
var info struct {
|
|
StatsReset string
|
|
Dealloc int64
|
|
}
|
|
if err := db.Raw("SELECT stats_reset::text, dealloc FROM public.pg_stat_statements_info").Scan(&info).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var version string
|
|
if err := db.Raw("SELECT version()").Scan(&version).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var settings struct {
|
|
FullPageWrites string
|
|
CheckpointTimeout string
|
|
}
|
|
if err := db.Raw("SELECT current_setting('full_page_writes') AS full_page_writes, current_setting('checkpoint_timeout') AS checkpoint_timeout").Scan(&settings).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Logf("PG %s stats_reset=%s dealloc=%d full_page_writes=%s checkpoint_timeout=%s", version, info.StatsReset, info.Dealloc, settings.FullPageWrites, settings.CheckpointTimeout)
|
|
}
|
|
|
|
func TestClientIpSyncScale(t *testing.T) {
|
|
for _, n := range scaleSizes(t, 1000, 10000, 20000) {
|
|
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
|
cleanup, err := testpg.IsolatePackage("client_ip_sync_scale")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(cleanup)
|
|
setupScaleDB(t)
|
|
db := database.GetDB()
|
|
resetScaleTables(t, db, "inbound_client_ips", "node_client_ips")
|
|
logClientIpPGContext(t)
|
|
now := time.Now().Unix()
|
|
makeReport := func(node int, advance int64) ([]model.InboundClientIps, map[string][]model.ClientIpEntry) {
|
|
rows := make([]model.InboundClientIps, n)
|
|
attribution := make(map[string][]model.ClientIpEntry, n)
|
|
for i := range n {
|
|
email := fmt.Sprintf("sync-%07d@scale", i)
|
|
entries := []model.ClientIpEntry{{IP: fmt.Sprintf("2001:db8:%x::%x", node+1, i+1), Timestamp: now + int64(node) + advance}}
|
|
b, err := json.Marshal(entries)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows[i] = model.InboundClientIps{ClientEmail: email, Ips: string(b)}
|
|
attribution[email] = entries
|
|
}
|
|
return rows, attribution
|
|
}
|
|
apply := func(node int, rows []model.InboundClientIps, attrs map[string][]model.ClientIpEntry) error {
|
|
if err := (&InboundService{}).MergeInboundClientIps(rows); err != nil {
|
|
return err
|
|
}
|
|
return (&InboundService{}).RecordLocalClientIps(fmt.Sprintf("bench-node-%d", node), attrs)
|
|
}
|
|
for node := range 4 {
|
|
rows, attrs := makeReport(node, 0)
|
|
if err := apply(node, rows, attrs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
flat := countClientIpSQL(t, "inbound_client_ips")
|
|
attr := countClientIpSQL(t, "node_client_ips")
|
|
for phase, advance := range []int64{0, 10, 20} {
|
|
t.Run([]string{"unchanged", "advancing", "contention"}[phase], func(t *testing.T) {
|
|
reports := make([][]model.InboundClientIps, 4)
|
|
attrs := make([]map[string][]model.ClientIpEntry, 4)
|
|
for node := range 4 {
|
|
reports[node], attrs[node] = makeReport(node, advance)
|
|
}
|
|
before := clientIpPGSnapshot(t)
|
|
fs, fu, fa := flat.selects.Load(), flat.updates.Load(), flat.affected.Load()
|
|
as, ac, aa := attr.selects.Load(), attr.creates.Load(), attr.affected.Load()
|
|
start := time.Now()
|
|
if phase == 2 {
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 4)
|
|
for node := range 4 {
|
|
wg.Go(func() { errs <- apply(node, reports[node], attrs[node]) })
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
} else {
|
|
for node := range 4 {
|
|
if err := apply(node, reports[node], attrs[node]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
elapsed := time.Since(start)
|
|
after := clientIpPGSnapshot(t)
|
|
t.Logf("N=%d reports=4 phase=%s elapsed=%v flat_SELECTs=%d CAS_attempts=%d CAS_misses=%d flat_affected=%d attribution_SELECTs=%d upsert_attempts=%d attribution_affected=%d", n, t.Name(), elapsed,
|
|
flat.selects.Load()-fs, flat.updates.Load()-fu, flat.updates.Load()-fu-(flat.affected.Load()-fa), flat.affected.Load()-fa,
|
|
attr.selects.Load()-as, attr.creates.Load()-ac, attr.affected.Load()-aa)
|
|
logClientIpPGDelta(t, before, after)
|
|
var actual []model.InboundClientIps
|
|
if err := db.Find(&actual).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(actual) != n {
|
|
t.Fatalf("flat rows=%d, want %d", len(actual), n)
|
|
}
|
|
for _, row := range actual {
|
|
var entries []model.ClientIpEntry
|
|
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := make(map[string]int64, len(entries))
|
|
for _, entry := range entries {
|
|
got[entry.IP] = entry.Timestamp
|
|
}
|
|
want := make(map[string]int64, 4)
|
|
for node := range 4 {
|
|
for _, entry := range attrs[node][row.ClientEmail] {
|
|
want[entry.IP] = entry.Timestamp
|
|
}
|
|
}
|
|
if len(entries) != 4 || !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("%s entries=%v, want %v", row.ClientEmail, entries, want)
|
|
}
|
|
}
|
|
trees, err := (&InboundService{}).GetClientIpsByGuid()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for node := range 4 {
|
|
if !reflect.DeepEqual(trees[fmt.Sprintf("bench-node-%d", node)], attrs[node]) {
|
|
t.Fatalf("node %d attribution does not match exact report", node)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
logClientIpPGContext(t)
|
|
})
|
|
}
|
|
}
|