fix(node-sync): keep node baseline while a sibling inbound still reports the email (#5202)

The orphan sweeps in setRemoteTrafficLocked deleted the (node, email)
baseline row unconditionally whenever an email was missing from one
inbound's snapshot stats — even though baselines are keyed per node, not
per inbound. For a client attached to two inbounds of the same node whose
stats the node reports under only one of them, the sweep for the other
inbound deleted the baseline at the end of every sync cycle. Depending on
inbound order, the baseline written earlier in the same transaction was
wiped each time, so the next cycle computed delta against a missing
baseline (zero) and the client's traffic froze permanently.

Scope both sweeps to the union of emails across the whole snapshot: a
baseline is only dropped when the email left the node entirely.
This commit is contained in:
MHSanaei
2026-06-11 21:20:38 +02:00
parent 1508666e52
commit 21143a6d72
2 changed files with 80 additions and 1 deletions
@@ -226,6 +226,54 @@ func TestClientGoneFromOneNode_KeepsSharedEmailRow(t *testing.T) {
assertUpDown(t, readTraffic(t, db, email), 140, 140, "node 2 keeps accruing")
}
// TestStatsUnderSiblingInbound_KeepsNodeBaseline reproduces the recurring
// sweep bug behind #5202: the client is attached to two inbounds of the SAME
// node, the node reports its stats under n1-a only, but the master-side row
// is owned by n1-b's mirror. The per-email sweep for n1-b must not drop the
// (node, email) baseline that n1-a's delta computation needs — doing so every
// cycle froze the client's counter permanently.
func TestStatsUnderSiblingInbound_KeepsNodeBaseline(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInboundWithClient(t, db, 1, "n1-a", 41001, "fresh")
createNodeInbound(t, db, 1, "n1-b", 41002)
svc := &InboundService{}
const email = "fresh"
var ibB model.Inbound
if err := db.Where("tag = ?", "n1-b").First(&ibB).Error; err != nil {
t.Fatalf("load n1-b: %v", err)
}
// Master-side row created when the client was added on the panel, owned by
// n1-b's mirror (e.g. the client form targeted that inbound).
if err := db.Create(&xray.ClientTraffic{InboundId: ibB.Id, Email: email, Enable: true}).Error; err != nil {
t.Fatalf("seed master row: %v", err)
}
settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
sync := func(up, down int64) {
t.Helper()
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
{Tag: "n1-a", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: up, Down: down, Enable: true}}},
{Tag: "n1-b", Settings: `{"clients": []}`},
}}
if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
t.Fatalf("sync: %v", err)
}
}
sync(630, 630)
var baselines int64
if err := db.Model(&model.NodeClientTraffic{}).Where("node_id = ? AND email = ?", 1, email).Count(&baselines).Error; err != nil {
t.Fatalf("count baselines: %v", err)
}
if baselines != 1 {
t.Fatalf("baseline must survive the sibling-inbound sweep, found %d rows", baselines)
}
sync(700, 700)
assertUpDown(t, readTraffic(t, db, email), 70, 70, "delta accrues once baseline survives")
}
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}