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
@@ -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)}
awgInbound := &model.Inbound{Tag: "awg-dual", Enable: true, Port: 51824, Protocol: model.AmneziaWG, Settings: string(awgSettings)}
if err := db.Create(wgInbound).Error; err != nil {
t.Fatalf("create wg inbound: %v", err)
}
// The lower-id tunnel owns the shared record's peer fields: AmneziaWG first.
if err := db.Create(awgInbound).Error; err != nil {
t.Fatalf("create awg inbound: %v", err)
}
if err := db.Create(wgInbound).Error; err != nil {
t.Fatalf("create wg inbound: %v", err)
}
svc := ClientService{}
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)
}
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"))