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
+114 -16
View File
@@ -105,7 +105,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
tx = database.GetDB()
}
if err := s.validateProtocolClients(tx, inboundId, clients, detachEmails, prune); err != nil {
var inbound model.Inbound
if err := tx.Select("protocol").Where("id = ?", inboundId).Take(&inbound).Error; err != nil {
return err
}
if err := s.validateProtocolClients(tx, inbound.Protocol, inboundId, clients, detachEmails, prune); err != nil {
return err
}
@@ -137,6 +141,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
}
}
isTunnel := inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG
var onLowerTunnel map[int]struct{}
if isTunnel && len(existing) > 0 {
ids := make([]int, 0, len(existing))
for _, row := range existing {
ids = append(ids, row.Id)
}
var err error
if onLowerTunnel, err = clientsOnLowerTunnel(tx, inboundId, ids); err != nil {
return err
}
}
idByEmail := make(map[string]int, len(emails))
pending := make(map[string]*model.ClientRecord, len(emails))
toCreate := make([]*model.ClientRecord, 0, len(emails))
@@ -161,6 +178,8 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
before := *row
applyClientRecordMerge(row, incoming)
_, outranked := onLowerTunnel[row.Id]
keepLinkOwnedFields(row, &before, isTunnel && !outranked)
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
row.UpdatedAt = preservedUpdatedAt
@@ -222,7 +241,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
wantedIds = append(wantedIds, id)
}
if err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune); err != nil {
relinked, err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune)
if err != nil {
return err
}
if err := settleRecordFlows(tx, relinked); err != nil {
return err
}
@@ -260,19 +283,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
return nil
}
// reconcileInboundLinks writes only the client_inbounds rows that differ. prune
// also removes links absent from wantedFlow, which only a full sync may do.
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) error {
// reconcileInboundLinks writes only the client_inbounds rows that differ and returns
// their clients. prune also removes links absent from wantedFlow, as only a full sync may.
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) ([]int, error) {
var current []model.ClientInbound
if prune {
if err := tx.Where("inbound_id = ?", inboundId).Find(&current).Error; err != nil {
return err
return nil, err
}
} else {
for _, batch := range chunkInts(wantedIds, sqlInChunk) {
var rows []model.ClientInbound
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
return err
return nil, err
}
current = append(current, rows...)
}
@@ -300,7 +323,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
var ids []int
if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
return err
return nil, err
}
for _, id := range ids {
if _, keep := wantedFlow[id]; !keep {
@@ -325,7 +348,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
for _, batch := range chunkInts(toDelete, sqlInChunk) {
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
Delete(&model.ClientInbound{}).Error; err != nil {
return err
return nil, err
}
}
for flow, ids := range toUpdate {
@@ -333,7 +356,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
if err := tx.Model(&model.ClientInbound{}).
Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
Update("flow_override", flow).Error; err != nil {
return err
return nil, err
}
}
}
@@ -344,8 +367,87 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
Columns: []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
}).CreateInBatches(toInsert, 200).Error; err != nil {
return nil, err
}
}
relinked := append([]int(nil), toDelete...)
for _, ids := range toUpdate {
relinked = append(relinked, ids...)
}
for _, link := range toInsert {
relinked = append(relinked, link.ClientId)
}
return relinked, nil
}
// keepLinkOwnedFields undoes the merge of the record fields one inbound cannot decide
// alone, or syncing a client's inbounds in turn rewrites the record back and forth.
func keepLinkOwnedFields(row, before *model.ClientRecord, ownsTunnelPeer bool) {
row.Flow = before.Flow
if ownsTunnelPeer {
return
}
row.PrivateKey = before.PrivateKey
row.PublicKey = before.PublicKey
row.AllowedIPs = before.AllowedIPs
row.PreSharedKey = before.PreSharedKey
row.KeepAlive = before.KeepAlive
}
// clientsOnLowerTunnel returns the clients also attached to a WireGuard/AmneziaWG
// inbound with a lower id than inboundId: that tunnel owns their record's peer fields.
func clientsOnLowerTunnel(tx *gorm.DB, inboundId int, clientIds []int) (map[int]struct{}, error) {
out := make(map[int]struct{})
for _, batch := range chunkInts(clientIds, sqlInChunk) {
var ids []int
if err := tx.Table("client_inbounds").
Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id").
Where("client_inbounds.client_id IN ? AND client_inbounds.inbound_id < ? AND inbounds.protocol IN ?",
batch, inboundId, []model.Protocol{model.WireGuard, model.AmneziaWG}).
Distinct().Pluck("client_inbounds.client_id", &ids).Error; err != nil {
return nil, err
}
for _, id := range ids {
out[id] = struct{}{}
}
}
return out, nil
}
// settleRecordFlows sets the record flow of each still-linked client to its
// effective flow (EffectiveFlow's rule); an unlinked record keeps its own.
func settleRecordFlows(tx *gorm.DB, clientIds []int) error {
for _, batch := range chunkInts(clientIds, sqlInChunk) {
var links []model.ClientInbound
if err := tx.Select("client_id", "flow_override").
Where("client_id IN ?", batch).
Order("inbound_id ASC").
Find(&links).Error; err != nil {
return err
}
effective := make(map[int]string, len(batch))
for _, link := range links {
if flow, seen := effective[link.ClientId]; !seen || (flow == "" && link.FlowOverride != "") {
effective[link.ClientId] = link.FlowOverride
}
}
var records []model.ClientRecord
if err := tx.Select("id", "flow").Where("id IN ?", batch).Find(&records).Error; err != nil {
return err
}
idsByFlow := make(map[string][]int)
for _, rec := range records {
if flow, linked := effective[rec.Id]; linked && flow != rec.Flow {
idsByFlow[flow] = append(idsByFlow[flow], rec.Id)
}
}
for flow, ids := range idsByFlow {
if err := tx.Model(&model.ClientRecord{}).Where("id IN ?", ids).
UpdateColumn("flow", flow).Error; err != nil {
return err
}
}
}
return nil
}
@@ -418,12 +520,8 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId
// validateProtocolClients runs inside the write every path linking clients to an inbound
// shares, so a client the protocol's core config would refuse never reaches it.
func (s *ClientService) validateProtocolClients(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error {
var inbound model.Inbound
if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
return err
}
switch inbound.Protocol {
func (s *ClientService) validateProtocolClients(tx *gorm.DB, protocol model.Protocol, inboundID int, changed []model.Client, detached []string, prune bool) error {
switch protocol {
case model.TUIC:
return s.validateTuicIdentities(tx, inboundID, changed, detached, prune)
case model.MASQUE: