fix(node-sync): keep shared client traffic row when email still lives on other inbounds

client_traffics is the per-email accumulator shared across every inbound
and node the client is attached to. setRemoteTrafficLocked deleted it
unguarded in two sweeps — when a node inbound vanished from the snapshot
(node reinstall, tag change, another master's reconcile on a shared
node) and when an email left one inbound's stats — even though the
email was still attached elsewhere. The next sync then re-seeded the
row with that node's counter alone, so the panel showed the last
changed panel's number instead of the summed total.

Guard both sweeps with emailUsedByOtherInbounds, matching what the
manual-edit path (updateClientTraffics) already does. Truly removed
clients are still cleaned up by the zero-attachment sweep.
This commit is contained in:
MHSanaei
2026-06-11 14:28:09 +02:00
parent dc52e725b6
commit 8258a26fbf
3 changed files with 108 additions and 8 deletions
+32 -7
View File
@@ -411,10 +411,27 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
return false, err
}
}
}
if err := tx.Where("inbound_id = ?", c.Id).
Delete(&xray.ClientTraffic{}).Error; err != nil {
return false, err
// The per-email row is the shared accumulator across every inbound
// (and node) the email is attached to. Only drop it when this was the
// email's last inbound — wiping it while a sibling still feeds it
// loses the summed history, and the next node sync would re-seed the
// row with that node's counter alone.
sharedEmails, sErr := s.emailsUsedByOtherInbounds(goneEmails, c.Id)
if sErr != nil {
return false, sErr
}
delEmails := make([]string, 0, len(goneEmails))
for _, e := range goneEmails {
if !sharedEmails[strings.ToLower(strings.TrimSpace(e))] {
delEmails = append(delEmails, e)
}
}
for _, batch := range chunkStrings(delEmails, sqliteMaxVars) {
if err := tx.Where("inbound_id = ? AND email IN ?", c.Id, batch).
Delete(&xray.ClientTraffic{}).Error; err != nil {
return false, err
}
}
}
if err := s.clientService.DetachInbound(tx, c.Id); err != nil {
return false, err
@@ -523,9 +540,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
Delete(&model.NodeClientTraffic{}).Error; err != nil {
return false, err
}
if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
Delete(&xray.ClientTraffic{}).Error; err != nil {
return false, err
// Same shared-accumulator rule as the inbound-removal sweep above:
// keep the row while another inbound still references the email.
stillUsed, uErr := s.emailUsedByOtherInbounds(existing.Email, c.Id)
if uErr != nil {
return false, uErr
}
if !stillUsed {
if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
Delete(&xray.ClientTraffic{}).Error; err != nil {
return false, err
}
}
structuralChange = true
}