perf(ip-sync): avoid redundant client IP reads and writes (#6930)

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
This commit is contained in:
Hamed
2026-10-25 16:43:18 +03:30
committed by GitHub
parent 952c1c751a
commit 8eb58a4a14
22 changed files with 1273 additions and 247 deletions
@@ -0,0 +1,36 @@
package service
import (
"os"
"strings"
"testing"
)
func TestClientIpSyncPostgres(t *testing.T) {
if os.Getenv("XUI_DB_TYPE") != "postgres" || strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run PostgreSQL IP sync regressions")
}
for _, tc := range []struct {
name string
run func(*testing.T)
}{
{"seeded snapshot", TestClientIpSyncSeededSnapshot},
{"duplicates", TestClientIpSyncDuplicatePayloadsKeepUnion},
{"raw snapshot retry", TestClientIpSyncRawSnapshotAndSingleRetry},
{"create conflict", TestClientIpSyncCreateConflictMergesWinner},
{"exhaustion rollback", TestClientIpSyncCASExhaustionRollsBack},
{"concurrent writer", TestMergeInboundClientIps_RetriesAfterConcurrentWriter},
{"attribution unchanged", TestNodeClientIpCanonicalUnchangedSkipsUpsert},
{"remote ids", TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId},
{"attribution round trip", TestRecordLocalClientIps_RoundTripByGuid},
{"attribution expiry", TestRecordLocalClientIps_MergesAndDropsStale},
{"attribution empty", TestUpsertNodeClientIps_EmptyMergeDeletesRow},
{"retention", TestPruneStaleClientIpsExpiresUnobservedRows},
{"cloned GUID", TestMergeClientIpsByGuid_RemapsClonedNodeSubtree},
{"maximum timestamps", TestMergeInboundClientIps_DedupKeepsMaxTimestamp},
{"stale merge", TestMergeInboundClientIps_DropsStaleIps},
{"stale insert", TestMergeInboundClientIps_SkipsAllStaleCreate},
} {
t.Run(tc.name, tc.run)
}
}
@@ -0,0 +1,206 @@
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)
})
}
}
@@ -29,10 +29,10 @@ func TestPruneStaleClientIpsExpiresUnobservedRows(t *testing.T) {
}
seed := []any{
&model.InboundClientIps{ClientEmail: "offline", Ips: marshalIps(t, clientIpEntry{IP: "198.51.100.7", Timestamp: stale})},
&model.InboundClientIps{ClientEmail: "offline", Ips: marshalIps(t, model.ClientIpEntry{IP: "198.51.100.7", Timestamp: stale})},
&model.InboundClientIps{ClientEmail: "mixed", Ips: marshalIps(t,
clientIpEntry{IP: "198.51.100.8", Timestamp: stale},
clientIpEntry{IP: "203.0.113.9", Timestamp: fresh})},
model.ClientIpEntry{IP: "198.51.100.8", Timestamp: stale},
model.ClientIpEntry{IP: "203.0.113.9", Timestamp: fresh})},
&model.NodeClientIp{NodeGuid: "g1", Email: "node-offline", Ips: mkNode(model.ClientIpEntry{IP: "198.51.100.9", Timestamp: stale})},
&model.NodeClientIp{NodeGuid: "g1", Email: "node-fresh", Ips: mkNode(model.ClientIpEntry{IP: "203.0.113.10", Timestamp: fresh})},
}
+47 -73
View File
@@ -47,23 +47,11 @@ func (s *InboundService) GetNodeInboundClientIps(nodeID int) ([]model.InboundCli
return ips, nil
}
// clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than
// 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps
// the synced blob bounded and stops the master's push-back from resurrecting IPs that
// a node has already pruned (otherwise the merge defeats the eviction cluster-wide).
// Match the scanner's 30-minute retention so sync cannot resurrect pruned IPs.
const clientIpStaleAfterSeconds = int64(30 * 60)
// clientIpEntry is the on-disk shape of each element of InboundClientIps.Ips. Tags
// match job.IPWithTimestamp so the blob round-trips with the access.log scanner.
type clientIpEntry struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
// mergeClientIpEntries unions old and incoming IP observations, dropping anything
// older than cutoff, keeping the most recent timestamp per IP, and returning the
// result sorted newest-first.
func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientIpEntry {
// mergeClientIpEntries keeps each fresh IP's maximum timestamp.
func mergeClientIpEntries(old, incoming []model.ClientIpEntry, cutoff int64) []model.ClientIpEntry {
ipMap := make(map[string]int64, len(old)+len(incoming))
for _, e := range old {
if e.Timestamp < cutoff {
@@ -79,19 +67,14 @@ func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientI
ipMap[e.IP] = e.Timestamp
}
}
out := make([]clientIpEntry, 0, len(ipMap))
out := make([]model.ClientIpEntry, 0, len(ipMap))
for ip, ts := range ipMap {
out = append(out, clientIpEntry{IP: ip, Timestamp: ts})
out = append(out, model.ClientIpEntry{IP: ip, Timestamp: ts})
}
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
return out
}
// MergeInboundClientIps folds client IPs synced from another node into the local
// inbound_client_ips table without double-counting an IP seen on multiple nodes and
// without resurrecting stale entries. Existing rows are updated in place; brand-new
// clients (typically node-only clients with no local row) are created with a fresh
// local id.
// MergeInboundClientIps unions remote observations without losing concurrent scanner writes.
func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClientIps) error {
db := database.GetDB()
var currentIps []model.InboundClientIps
@@ -107,10 +90,7 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
now := time.Now().Unix()
cutoff := now - clientIpStaleAfterSeconds
// Node syncs run concurrently (one goroutine per node) and shared clients
// appear in several nodes' reports. Locking rows in each node's arbitrary
// report order lets two merges grab the same rows in opposite order, which
// Postgres aborts as a deadlock (40P01) — take them in one global order.
// All sync writers take row locks in email order to avoid PostgreSQL deadlocks.
sort.Slice(incomingIps, func(i, j int) bool {
return incomingIps[i].ClientEmail < incomingIps[j].ClientEmail
})
@@ -127,39 +107,42 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
continue
}
var incomingEntries []clientIpEntry
var incomingEntries []model.ClientIpEntry
_ = json.Unmarshal([]byte(incoming.Ips), &incomingEntries)
current, exists := currentMap[incoming.ClientEmail]
if !exists {
// New client we've never seen locally. Drop stale entries up front and
// skip the row entirely if nothing is fresh, so we don't persist a row
// that is dead on arrival.
// A new row must contain at least one fresh observation.
fresh := mergeClientIpEntries(nil, incomingEntries, cutoff)
if len(fresh) == 0 {
continue
}
b, _ := json.Marshal(fresh)
incoming.Ips = string(b)
// Never carry the remote node's primary key into the local table: id
// spaces are independent across nodes and the remote id would collide
// with an unrelated local row. OnConflict guards the race where
// check_client_ip_job creates the same brand-new email between the
// snapshot above and this insert.
incoming.Ips, _ = model.MarshalClientIpEntries(fresh)
// Remote primary keys belong to an independent database.
incoming.Id = 0
if err := tx.Clauses(clause.OnConflict{
result := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client_email"}},
DoNothing: true,
}).Create(&incoming).Error; err != nil {
}).Create(&incoming)
if result.Error != nil {
tx.Rollback()
return result.Error
}
if result.RowsAffected == 1 {
currentMap[incoming.ClientEmail] = &incoming
continue
}
current = &model.InboundClientIps{}
if err := tx.Where("client_email = ?", incoming.ClientEmail).First(current).Error; err != nil {
tx.Rollback()
return err
}
continue
currentMap[incoming.ClientEmail] = current
}
// check_client_ip_job rewrites this blob too; an unconditional Update loses
// whichever writer commits second and its remote IPs with it (#6587).
if err := mergeExistingClientIps(tx, current.Id, incomingEntries, cutoff); err != nil {
if err := mergeExistingClientIps(tx, current, incomingEntries, cutoff); err != nil {
tx.Rollback()
return err
}
@@ -183,33 +166,33 @@ func CasUpdateInboundClientIps(tx *gorm.DB, id int, expectedIps, newIps string)
return res.RowsAffected == 1, nil
}
// mergeExistingClientIps folds incoming into the row at id under a CAS loop so
// a concurrent check_client_ip_job write cannot erase the merge (#6587).
func mergeExistingClientIps(tx *gorm.DB, id int, incoming []clientIpEntry, cutoff int64) error {
for attempt := 0; attempt < ClientIpCasRetries; attempt++ {
var row model.InboundClientIps
if err := tx.Where("id = ?", id).First(&row).Error; err != nil {
return err
}
var oldEntries []clientIpEntry
if row.Ips != "" {
_ = json.Unmarshal([]byte(row.Ips), &oldEntries)
// Seed the first CAS from the preload; only a competing write requires a re-read.
func mergeExistingClientIps(tx *gorm.DB, current *model.InboundClientIps, incoming []model.ClientIpEntry, cutoff int64) error {
for attempt := range ClientIpCasRetries {
var oldEntries []model.ClientIpEntry
if current.Ips != "" {
_ = json.Unmarshal([]byte(current.Ips), &oldEntries)
}
merged := mergeClientIpEntries(oldEntries, incoming, cutoff)
b, _ := json.Marshal(merged)
mergedStr := string(b)
if row.Ips == mergedStr {
mergedStr, _ := model.MarshalClientIpEntries(merged)
if current.Ips == mergedStr {
return nil
}
ok, err := CasUpdateInboundClientIps(tx, id, row.Ips, mergedStr)
ok, err := CasUpdateInboundClientIps(tx, current.Id, current.Ips, mergedStr)
if err != nil {
return err
}
if ok {
current.Ips = mergedStr
return nil
}
if attempt+1 < ClientIpCasRetries {
if err := tx.Where("id = ?", current.Id).First(current).Error; err != nil {
return err
}
}
}
return fmt.Errorf("inbound_client_ips id=%d: exhausted CAS retries merging client IPs", id)
return fmt.Errorf("inbound_client_ips id=%d: exhausted CAS retries merging client IPs", current.Id)
}
func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error {
@@ -250,13 +233,7 @@ func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error)
return "", nil
}
// Try to parse as new format (with timestamps)
type IPWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []IPWithTimestamp
var ipsWithTime []model.ClientIpEntry
err = json.Unmarshal([]byte(InboundClientIps.Ips), &ipsWithTime)
// If successfully parsed as new format, return with timestamps
@@ -270,12 +247,9 @@ func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error)
err = json.Unmarshal([]byte(InboundClientIps.Ips), &oldIps)
if err == nil && len(oldIps) > 0 {
// Convert old format to new format with current timestamp
newIpsWithTime := make([]IPWithTimestamp, len(oldIps))
newIpsWithTime := make([]model.ClientIpEntry, len(oldIps))
for i, ip := range oldIps {
newIpsWithTime[i] = IPWithTimestamp{
IP: ip,
Timestamp: time.Now().Unix(),
}
newIpsWithTime[i] = model.ClientIpEntry{IP: ip, Timestamp: time.Now().Unix()}
}
result, _ := json.Marshal(newIpsWithTime)
return string(result), nil
@@ -309,7 +283,7 @@ func (s *InboundService) PruneStaleClientIps() error {
return err
}
for _, row := range rows {
var entries []clientIpEntry
var entries []model.ClientIpEntry
if row.Ips != "" {
// Legacy blobs without timestamps stay untouched; the next scan rewrites them.
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
@@ -326,8 +300,8 @@ func (s *InboundService) PruneStaleClientIps() error {
if len(kept) == len(entries) {
continue
}
b, _ := json.Marshal(kept)
if err := db.Model(&model.InboundClientIps{}).Where("id = ?", row.Id).Update("ips", string(b)).Error; err != nil {
encoded, _ := model.MarshalClientIpEntries(kept)
if err := db.Model(&model.InboundClientIps{}).Where("id = ?", row.Id).Update("ips", encoded).Error; err != nil {
return err
}
}
@@ -2,27 +2,37 @@ package service
import (
"encoding/json"
"fmt"
"path/filepath"
"reflect"
"sync/atomic"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// setupClientIpTestDB spins up a throwaway SQLite database (migrations + seeders)
// for a single test, mirroring the harness used by the other service tests.
func setupClientIpTestDB(t *testing.T) {
t.Helper()
cleanup, err := testpg.IsolatePackage("client_ip_sync")
if err != nil {
t.Fatal(err)
}
t.Cleanup(cleanup)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
}
func marshalIps(t *testing.T, entries ...clientIpEntry) string {
func marshalIps(t *testing.T, entries ...model.ClientIpEntry) string {
t.Helper()
b, err := json.Marshal(entries)
if err != nil {
@@ -43,7 +53,7 @@ func readClientIps(t *testing.T, email string) (map[string]int64, bool) {
if err != nil {
t.Fatalf("read client ips for %s: %v", email, err)
}
var entries []clientIpEntry
var entries []model.ClientIpEntry
if row.Ips != "" {
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
t.Fatalf("unmarshal stored ips for %s: %v", email, err)
@@ -62,7 +72,7 @@ func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T)
now := time.Now().Unix()
// Local client occupies id 1.
local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})}
local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now})}
if err := db.Create(local).Error; err != nil {
t.Fatalf("seed local row: %v", err)
}
@@ -72,7 +82,7 @@ func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T)
incoming := []model.InboundClientIps{{
Id: 1,
ClientEmail: "node@x",
Ips: marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now}),
Ips: marshalIps(t, model.ClientIpEntry{IP: "2.2.2.2", Timestamp: now}),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
@@ -103,7 +113,7 @@ func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) {
if err := db.Create(&model.InboundClientIps{
ClientEmail: "a@x",
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}),
Ips: marshalIps(t, model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}),
}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
@@ -111,8 +121,8 @@ func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) {
incoming := []model.InboundClientIps{{
ClientEmail: "a@x",
Ips: marshalIps(t,
clientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins
clientIpEntry{IP: "2.2.2.2", Timestamp: now - 10},
model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins
model.ClientIpEntry{IP: "2.2.2.2", Timestamp: now - 10},
),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
@@ -139,8 +149,8 @@ func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) {
if err := db.Create(&model.InboundClientIps{
ClientEmail: "a@x",
Ips: marshalIps(t,
clientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale
clientIpEntry{IP: "fresh", Timestamp: now - 60},
model.ClientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale
model.ClientIpEntry{IP: "fresh", Timestamp: now - 60},
),
}).Error; err != nil {
t.Fatalf("seed: %v", err)
@@ -149,8 +159,8 @@ func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) {
incoming := []model.InboundClientIps{{
ClientEmail: "a@x",
Ips: marshalIps(t,
clientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale
clientIpEntry{IP: "incFresh", Timestamp: now - 10},
model.ClientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale
model.ClientIpEntry{IP: "incFresh", Timestamp: now - 10},
),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
@@ -178,7 +188,7 @@ func TestMergeInboundClientIps_SkipsAllStaleCreate(t *testing.T) {
incoming := []model.InboundClientIps{{
ClientEmail: "b@x",
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}),
Ips: marshalIps(t, model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
@@ -194,7 +204,7 @@ func TestMergeInboundClientIps_SkipsBlankRows(t *testing.T) {
now := time.Now().Unix()
incoming := []model.InboundClientIps{
{ClientEmail: "", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})},
{ClientEmail: "", Ips: marshalIps(t, model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now})},
{ClientEmail: "c@x", Ips: ""},
}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
@@ -217,13 +227,13 @@ func TestCasUpdateInboundClientIps_MatchAndMismatch(t *testing.T) {
seed := &model.InboundClientIps{
ClientEmail: "cas@x",
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now}),
Ips: marshalIps(t, model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now}),
}
if err := db.Create(seed).Error; err != nil {
t.Fatalf("seed: %v", err)
}
next := marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now})
next := marshalIps(t, model.ClientIpEntry{IP: "2.2.2.2", Timestamp: now})
ok, err := CasUpdateInboundClientIps(db, seed.Id, "not-the-blob", next)
if err != nil {
t.Fatalf("stale CAS: %v", err)
@@ -258,13 +268,13 @@ func TestMergeInboundClientIps_RetriesAfterConcurrentWriter(t *testing.T) {
seed := &model.InboundClientIps{
ClientEmail: "race@x",
Ips: marshalIps(t, clientIpEntry{IP: "10.0.0.1", Timestamp: now - 30}),
Ips: marshalIps(t, model.ClientIpEntry{IP: "10.0.0.1", Timestamp: now - 30}),
}
if err := db.Create(seed).Error; err != nil {
t.Fatalf("seed: %v", err)
}
jobBlob := marshalIps(t, clientIpEntry{IP: "10.0.0.2", Timestamp: now - 10})
jobBlob := marshalIps(t, model.ClientIpEntry{IP: "10.0.0.2", Timestamp: now - 10})
const callback = "test:inbound_client_ips_cas_inject"
injected := false
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
@@ -294,7 +304,7 @@ func TestMergeInboundClientIps_RetriesAfterConcurrentWriter(t *testing.T) {
incoming := []model.InboundClientIps{{
ClientEmail: "race@x",
Ips: marshalIps(t, clientIpEntry{IP: "10.0.0.3", Timestamp: now}),
Ips: marshalIps(t, model.ClientIpEntry{IP: "10.0.0.3", Timestamp: now}),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
@@ -316,3 +326,314 @@ func TestMergeInboundClientIps_RetriesAfterConcurrentWriter(t *testing.T) {
t.Fatalf("pre-job IP should not resurrect after job replaced the blob: %v", ips)
}
}
type clientIpSQLCounts struct {
selects atomic.Int64
updates atomic.Int64
creates atomic.Int64
affected atomic.Int64
}
func countClientIpSQL(t *testing.T, table string) *clientIpSQLCounts {
t.Helper()
db := database.GetDB()
counts := &clientIpSQLCounts{}
name := "test:ip_sql_" + table
query := func(tx *gorm.DB) {
if tx.Statement.Table == table {
counts.selects.Add(1)
}
}
update := func(tx *gorm.DB) {
if tx.Statement.Table == table {
counts.updates.Add(1)
counts.affected.Add(tx.RowsAffected)
}
}
create := func(tx *gorm.DB) {
if tx.Statement.Table == table {
counts.creates.Add(1)
counts.affected.Add(tx.RowsAffected)
}
}
if err := db.Callback().Query().After("gorm:query").Register(name, query); err != nil {
t.Fatal(err)
}
if err := db.Callback().Update().After("gorm:update").Register(name, update); err != nil {
t.Fatal(err)
}
if err := db.Callback().Create().After("gorm:create").Register(name, create); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = db.Callback().Query().Remove(name)
_ = db.Callback().Update().Remove(name)
_ = db.Callback().Create().Remove(name)
})
return counts
}
func rawClientIpBlob(t *testing.T, entries ...model.ClientIpEntry) string {
t.Helper()
b, err := json.Marshal(entries)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func requireClientIpSet(t *testing.T, email string, want map[string]int64) {
t.Helper()
got, exists := readClientIps(t, email)
if !exists || !reflect.DeepEqual(got, want) {
t.Fatalf("%s IPs = %v (exists=%v), want %v", email, got, exists, want)
}
}
func TestClientIpSyncSeededSnapshot(t *testing.T) {
for _, changed := range []bool{false, true} {
t.Run(fmt.Sprintf("changed=%v", changed), func(t *testing.T) {
setupClientIpTestDB(t)
now := time.Now().Unix()
rows := make([]model.InboundClientIps, 3)
incoming := make([]model.InboundClientIps, 3)
for i := range rows {
rows[i] = model.InboundClientIps{ClientEmail: fmt.Sprintf("snapshot-%d@x", i), Ips: rawClientIpBlob(t,
model.ClientIpEntry{IP: "192.0.2.1", Timestamp: now - 10}, model.ClientIpEntry{IP: "192.0.2.2", Timestamp: now})}
incoming[i] = rows[i]
if changed {
incoming[i].Ips = rawClientIpBlob(t, model.ClientIpEntry{IP: "192.0.2.2", Timestamp: now + 1})
}
}
if err := database.GetDB().Create(&rows).Error; err != nil {
t.Fatal(err)
}
counts := countClientIpSQL(t, "inbound_client_ips")
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatal(err)
}
wantUpdates := int64(0)
if changed {
wantUpdates = 3
}
if counts.selects.Load() != 1 || counts.updates.Load() != wantUpdates {
t.Fatalf("SELECTs/CAS attempts = %d/%d, want 1/%d", counts.selects.Load(), counts.updates.Load(), wantUpdates)
}
for _, row := range rows {
ts := now
if changed {
ts++
}
requireClientIpSet(t, row.ClientEmail, map[string]int64{"192.0.2.1": now - 10, "192.0.2.2": ts})
}
})
}
}
func TestClientIpSyncDuplicatePayloadsKeepUnion(t *testing.T) {
setupClientIpTestDB(t)
now := time.Now().Unix()
seed := model.InboundClientIps{ClientEmail: "existing@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "192.0.2.1", Timestamp: now})}
if err := database.GetDB().Create(&seed).Error; err != nil {
t.Fatal(err)
}
var incoming []model.InboundClientIps
for _, email := range []string{"new@x", "existing@x"} {
for i := range 3 {
incoming = append(incoming, model.InboundClientIps{Id: 99, ClientEmail: email, Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: fmt.Sprintf("192.0.2.%d", i+1), Timestamp: now + int64(i)})})
}
}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatal(err)
}
for _, email := range []string{"new@x", "existing@x"} {
requireClientIpSet(t, email, map[string]int64{"192.0.2.1": now, "192.0.2.2": now + 1, "192.0.2.3": now + 2})
}
}
func TestClientIpSyncCreateConflictMergesWinner(t *testing.T) {
setupClientIpTestDB(t)
db := database.GetDB()
now := time.Now().Unix()
winner := rawClientIpBlob(t, model.ClientIpEntry{IP: "198.51.100.1", Timestamp: now})
const callback = "test:ip_create_winner"
injected := false
if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
row, ok := tx.Statement.Dest.(*model.InboundClientIps)
if injected || tx.Statement.Table != "inbound_client_ips" || !ok || row.ClientEmail != "winner@x" {
return
}
injected = true
if err := tx.Exec("INSERT INTO inbound_client_ips (client_email, ips) VALUES (?, ?)", row.ClientEmail, winner).Error; err != nil {
tx.AddError(err)
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
if err := (&InboundService{}).MergeInboundClientIps([]model.InboundClientIps{{ClientEmail: "winner@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "203.0.113.1", Timestamp: now + 1})}}); err != nil {
t.Fatal(err)
}
if !injected {
t.Fatal("create conflict was not injected")
}
requireClientIpSet(t, "winner@x", map[string]int64{"198.51.100.1": now, "203.0.113.1": now + 1})
}
func TestClientIpSyncCASExhaustionRollsBack(t *testing.T) {
setupClientIpTestDB(t)
db := database.GetDB()
now := time.Now().Unix()
rows := []model.InboundClientIps{
{ClientEmail: "a@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "192.0.2.1", Timestamp: now})},
{ClientEmail: "z@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "192.0.2.2", Timestamp: now})},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
counts := countClientIpSQL(t, "inbound_client_ips")
const callback = "test:ip_exhaustion"
attempts := 0
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table != "inbound_client_ips" {
return
}
where, ok := tx.Statement.Clauses["WHERE"].Expression.(clause.Where)
if !ok || len(where.Exprs) != 1 {
return
}
predicate, ok := where.Exprs[0].(clause.Expr)
if !ok || len(predicate.Vars) != 2 || predicate.Vars[0] != rows[1].Id {
return
}
attempts++
blob := rawClientIpBlob(t, model.ClientIpEntry{IP: "198.51.100.9", Timestamp: now + int64(attempts)})
if err := tx.Exec("UPDATE inbound_client_ips SET ips = ? WHERE id = ?", blob, rows[1].Id).Error; err != nil {
tx.AddError(err)
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Update().Remove(callback) })
err := (&InboundService{}).MergeInboundClientIps([]model.InboundClientIps{
{ClientEmail: "z@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "203.0.113.8", Timestamp: now})},
{ClientEmail: "a@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "203.0.113.7", Timestamp: now})},
})
want := fmt.Sprintf("inbound_client_ips id=%d: exhausted CAS retries merging client IPs", rows[1].Id)
if err == nil || err.Error() != want {
t.Fatalf("error = %v, want %q", err, want)
}
if attempts != 8 || counts.selects.Load() != 8 || counts.updates.Load() != 9 {
t.Fatalf("attempts/selects/updates = %d/%d/%d, want 8/8/9", attempts, counts.selects.Load(), counts.updates.Load())
}
for _, row := range rows {
var got model.InboundClientIps
if err := db.First(&got, row.Id).Error; err != nil {
t.Fatal(err)
}
if got.Ips != row.Ips {
t.Fatalf("rollback changed %s: %s, want %s", row.ClientEmail, got.Ips, row.Ips)
}
}
}
func TestNodeClientIpCanonicalUnchangedSkipsUpsert(t *testing.T) {
setupClientIpTestDB(t)
now := time.Now().Unix()
report := map[string][]model.ClientIpEntry{"u@x": {{IP: "192.0.2.1", Timestamp: now - 1}, {IP: "192.0.2.2", Timestamp: now}}}
row := model.NodeClientIp{NodeGuid: "guid", Email: "u@x", Ips: rawClientIpBlob(t, report["u@x"]...)}
if err := database.GetDB().Create(&row).Error; err != nil {
t.Fatal(err)
}
counts := countClientIpSQL(t, "node_client_ips")
if err := (&InboundService{}).RecordLocalClientIps("guid", report); err != nil {
t.Fatal(err)
}
if counts.selects.Load() != 1 || counts.creates.Load() != 0 || counts.updates.Load() != 0 {
t.Fatalf("unchanged attribution SELECT/create/update = %d/%d/%d, want 1/0/0", counts.selects.Load(), counts.creates.Load(), counts.updates.Load())
}
report["u@x"][1].Timestamp++
if err := (&InboundService{}).RecordLocalClientIps("guid", report); err != nil {
t.Fatal(err)
}
if counts.creates.Load() != 1 || counts.affected.Load() != 1 {
t.Fatalf("advancing attribution attempts/affected = %d/%d, want 1/1", counts.creates.Load(), counts.affected.Load())
}
trees, err := (&InboundService{}).GetClientIpsByGuid()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(trees["guid"]["u@x"], report["u@x"]) {
t.Fatalf("attribution = %v, want %v", trees, report)
}
}
func TestClientIpSyncRawSnapshotAndSingleRetry(t *testing.T) {
for _, contend := range []bool{false, true} {
t.Run(fmt.Sprintf("contention=%v", contend), func(t *testing.T) {
setupClientIpTestDB(t)
db := database.GetDB()
now := time.Now().Unix()
raw := fmt.Sprintf("[ {\"ip\":\"192.0.2.2\",\"timestamp\":%d}, {\"ip\":\"192.0.2.1\",\"timestamp\":%d} ]", now, now-1)
row := model.InboundClientIps{ClientEmail: "raw@x", Ips: raw}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
counts := countClientIpSQL(t, "inbound_client_ips")
injected := false
if contend {
const callback = "test:ip_one_miss"
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
if injected || tx.Statement.Table != "inbound_client_ips" {
return
}
where, ok := tx.Statement.Clauses["WHERE"].Expression.(clause.Where)
if !ok || len(where.Exprs) != 1 {
return
}
predicate, ok := where.Exprs[0].(clause.Expr)
if !ok || len(predicate.Vars) != 2 || predicate.Vars[0] != row.Id {
return
}
injected = true
winner := rawClientIpBlob(t, model.ClientIpEntry{IP: "198.51.100.1", Timestamp: now + 2})
if err := tx.Exec("UPDATE inbound_client_ips SET ips = ? WHERE id = ?", winner, row.Id).Error; err != nil {
tx.AddError(err)
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Update().Remove(callback) })
}
if err := (&InboundService{}).MergeInboundClientIps([]model.InboundClientIps{{ClientEmail: row.ClientEmail, Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "203.0.113.1", Timestamp: now + 1})}}); err != nil {
t.Fatal(err)
}
wantCount := int64(1)
want := map[string]int64{"192.0.2.1": now - 1, "192.0.2.2": now, "203.0.113.1": now + 1}
if contend {
wantCount = 2
want = map[string]int64{"198.51.100.1": now + 2, "203.0.113.1": now + 1}
if !injected {
t.Fatal("competing SQL update was not injected")
}
}
if counts.selects.Load() != wantCount || counts.updates.Load() != wantCount {
t.Fatalf("SELECTs/CAS=%d/%d, want %d/%d", counts.selects.Load(), counts.updates.Load(), wantCount, wantCount)
}
requireClientIpSet(t, row.ClientEmail, want)
var got model.InboundClientIps
if err := db.First(&got, row.Id).Error; err != nil {
t.Fatal(err)
}
var entries []model.ClientIpEntry
if err := json.Unmarshal([]byte(got.Ips), &entries); err != nil {
t.Fatal(err)
}
for i := 1; i < len(entries); i++ {
if entries[i-1].Timestamp > entries[i].Timestamp || (entries[i-1].Timestamp == entries[i].Timestamp && entries[i-1].IP > entries[i].IP) {
t.Fatalf("noncanonical persisted blob %s", got.Ips)
}
}
})
}
}
+12 -14
View File
@@ -18,9 +18,7 @@ import (
// observations under its panelGuid, and the master merges every node's
// guid-keyed report — never mixing in IPs a parent pushed down.
// mergeModelClientIpEntries unions old and incoming observations, drops anything
// older than cutoff, keeps the newest timestamp per IP, and sorts newest-first.
// It mirrors mergeClientIpEntries but operates on the exported wire type.
// Attribution excludes empty addresses and keeps each fresh IP's maximum timestamp.
func mergeModelClientIpEntries(old, incoming []model.ClientIpEntry, cutoff int64) []model.ClientIpEntry {
ipMap := make(map[string]int64, len(old)+len(incoming))
for _, e := range old {
@@ -41,13 +39,11 @@ func mergeModelClientIpEntries(old, incoming []model.ClientIpEntry, cutoff int64
for ip, ts := range ipMap {
out = append(out, model.ClientIpEntry{IP: ip, Timestamp: ts})
}
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
return out
}
// upsertNodeClientIps folds a guid's per-email observations into NodeClientIp,
// merging with whatever is already stored for that (guid, email) and dropping
// stale entries. Empty merged results delete the row so the table stays bounded.
// upsertNodeClientIps merges fresh observations per (guid, email).
// Empty results delete attribution instead of retaining expired rows.
func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry) error {
if guid == "" || len(perEmail) == 0 {
return nil
@@ -99,8 +95,11 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
}
continue
}
b, _ := json.Marshal(merged)
row := model.NodeClientIp{NodeGuid: guid, Email: email, Ips: string(b)}
encoded, _ := model.MarshalClientIpEntries(merged)
if current := existingByEmail[email]; current != nil && current.Ips == encoded {
continue
}
row := model.NodeClientIp{NodeGuid: guid, Email: email, Ips: encoded}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "node_guid"}, {Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"ips"}),
@@ -112,9 +111,7 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
return tx.Commit().Error
}
// RecordLocalClientIps stores this panel's own Xray observations under its
// panelGuid. Called by check_client_ip_job each scan with the live per-email IPs
// the local core reported.
// RecordLocalClientIps stores the scanner's local observations under this panel's GUID.
func (s *InboundService) RecordLocalClientIps(panelGuid string, observed map[string][]model.ClientIpEntry) error {
return upsertNodeClientIps(panelGuid, observed)
}
@@ -172,6 +169,7 @@ func (s *InboundService) GetClientIpsByGuid() (map[string]map[string][]model.Cli
if len(fresh) == 0 {
continue
}
model.SortClientIpEntries(fresh)
if out[row.NodeGuid] == nil {
out[row.NodeGuid] = make(map[string][]model.ClientIpEntry)
}
@@ -325,8 +323,8 @@ func pruneStaleNodeClientIps(cutoff int64) error {
if len(kept) == len(entries) {
continue
}
b, _ := json.Marshal(kept)
if err := db.Model(&model.NodeClientIp{}).Where("id = ?", row.Id).Update("ips", string(b)).Error; err != nil {
encoded, _ := model.MarshalClientIpEntries(kept)
if err := db.Model(&model.NodeClientIp{}).Where("id = ?", row.Id).Update("ips", encoded).Error; err != nil {
return err
}
}
@@ -28,8 +28,8 @@ func TestRecordLocalClientIps_RoundTripByGuid(t *testing.T) {
if len(got) != 2 {
t.Fatalf("want 2 entries, got %v", got)
}
if got[0].IP != "1.1.1.1" { // newest-first ordering
t.Fatalf("want newest first, got %v", got)
if got[0].IP != "2.2.2.2" || got[0].Timestamp != now-10 || got[1].IP != "1.1.1.1" || got[1].Timestamp != now {
t.Fatalf("want ascending timestamps, got %v", got)
}
}