fix(node-sync): do not latch node disable when master allows (#6870) (#6876)

* fix(node-sync): do not latch node disable when master allows (#6870)

nodeDisableIsStale trusted any node-reported enable=false whose limits matched
the master's, treating it as a genuine verdict and latching it on the master.
But matching limits no longer prove the verdict: a single stray false
(a depleted snapshot the master later re-enabled, a stale
disableInvalidClients hit, or a push that lagged a quota reset) leaves the
client stuck disabled on the node, and every master tick flips the client's
state again. This is the loop reported in #6870.

Make nodeDisableIsStale a single rule: stale iff the master's own deadline
and quota still permit the client. A genuine combined-quota or expiry
disable (masterLimitsAllowClient == false) still latches, preserving #4917.
Mirror the same rule in ClientTrafficEnableMergeExpr so the SQL UPDATE and
the Go in-memory replay cannot drift.

* fix(node-sync): keep node usage over quota latching a node disable

Dropping the matching-limits latch (#6870) let the master re-enable a
client the node had genuinely counted past its quota whenever the
master's own counters lagged, e.g. after a seeded-at-zero adoption,
reopening #4917. A node's enable=false now stays latched when the
node's own usage already spends the master's quota; otherwise the
master's limits decide, so a stray disable no longer loops.

Restores TestNodeQuotaDisable_SameExpiryStillLatches as it was on main.

---------

Co-authored-by: Alireza Ahmadi <alireza7@gmail.com>
This commit is contained in:
25f2002654
2026-10-23 22:34:46 +05:30
committed by GitHub
parent e2184777a0
commit f172bc3f10
3 changed files with 72 additions and 32 deletions
+10 -11
View File
@@ -226,7 +226,7 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
// mergeNodeClientTraffic folds one node-reported client into its central row.
// While frozen (a push pending or just landed) only the counters may move.
func (s *InboundService) mergeNodeClientTraffic(tx *gorm.DB, cs xray.ClientTraffic, frozen bool, deltaUp, deltaDown, now int64) error {
func (s *InboundService) mergeNodeClientTraffic(tx *gorm.DB, cs xray.ClientTraffic, canon nodeTrafficCounter, frozen bool, deltaUp, deltaDown, now int64) error {
if frozen {
return tx.Exec(
fmt.Sprintf(
@@ -254,7 +254,7 @@ func (s *InboundService) mergeNodeClientTraffic(tx *gorm.DB, cs xray.ClientTraff
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown,
cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
cs.Enable, now, deltaUp, deltaDown, canon.Up, canon.Down,
cs.Total,
cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
cs.LastOnline, cs.Email,
@@ -285,15 +285,14 @@ func masterLimitsAllowClient(master *xray.ClientTraffic, now, deltaUp, deltaDown
return true
}
// nodeDisableIsStale reports an enable=false the node decided against limits the
// master has since changed, so it must not latch back (#6228 / #4917).
func nodeDisableIsStale(master *xray.ClientTraffic, node xray.ClientTraffic, now, deltaUp, deltaDown int64) bool {
// nodeDisableIsStale reports a node-reported enable=false the master's limits no
// longer justify, so it must not latch back (#6228 / #4917 / #6870).
func nodeDisableIsStale(master *xray.ClientTraffic, nodeUsed, now, deltaUp, deltaDown int64) bool {
if master == nil {
return false
}
// Matching limits mean the node judged the client on the master's own terms:
// that verdict is genuine and still latches, as #4917 requires.
if node.ExpiryTime == master.ExpiryTime && node.Total == master.Total {
// A master seeded at zero on adoption undercounts; the node's own usage still binds (#4917).
if master.Total > 0 && nodeUsed >= master.Total {
return false
}
return masterLimitsAllowClient(master, now, deltaUp, deltaDown)
@@ -1117,7 +1116,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
// Only a real latch to disabled is structural; one-way merge never
// re-enables from the node.
enableChanged := !clientFrozen && existing.Enable && !cs.Enable &&
!nodeDisableIsStale(existing, cs, now, deltaUp, deltaDown)
!nodeDisableIsStale(existing, canon.Up+canon.Down, now, deltaUp, deltaDown)
metaChanged := !clientFrozen && (existing.Total != cs.Total || existing.Reset != cs.Reset || existing.ResetWeekday != cs.ResetWeekday)
if enableChanged || metaChanged || expiryChanged {
structuralChange = true
@@ -1175,7 +1174,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
next.Down = clampTrafficCounter(row.Down + deltaDown)
next.LastOnline = max(row.LastOnline, cs.LastOnline)
if !clientFrozen {
if !cs.Enable && !nodeDisableIsStale(row, cs, now, deltaUp, deltaDown) {
if !cs.Enable && !nodeDisableIsStale(row, canon.Up+canon.Down, now, deltaUp, deltaDown) {
next.Enable = false
}
next.ExpiryTime = mergeActivationExpiry(row.ExpiryTime, cs.ExpiryTime)
@@ -1185,7 +1184,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
// A multi-attached client is reported once per inbound and an idle one
// every tick: rewriting an unchanged row only holds its lock longer.
if next != *row {
if err := s.mergeNodeClientTraffic(tx, cs, clientFrozen, deltaUp, deltaDown, now); err != nil {
if err := s.mergeNodeClientTraffic(tx, cs, canon, clientFrozen, deltaUp, deltaDown, now); err != nil {
return false, err
}
*row = next