fix(clients): push bulk client changes to nodes only after the commit lands (#6181)

* fix(clients): apply bulk mutations after durable commit

* test(clients): guard bulk pushes behind commit

* fix(clients): fully delete remote bulk clients

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
n0ctal
2026-08-14 23:11:19 +05:00
committed by GitHub
parent b56b087254
commit c5dec64d36
2 changed files with 137 additions and 91 deletions
+51 -91
View File
@@ -652,7 +652,6 @@ func (s *ClientService) bulkAdjustInboundClients(
}
return res
}
prevSettings := oldInbound.Settings
oldInbound.Settings = string(newSettings)
// A flow change rewrites the user's xray config, which the lightweight
@@ -662,45 +661,6 @@ func (s *ClientService) bulkAdjustInboundClients(
res.needRestart = true
}
if oldInbound.NodeID != nil {
rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
for email := range foundEmails {
res.perEmailSkipped[email] = perr.Error()
delete(foundEmails, email)
}
} else {
if flowChanged {
push = false
}
// Large batches collapse into one reconcile push rather than M updates.
if push && len(foundEmails) > nodeBulkPushThreshold {
push = false
}
if push {
pushFailed := false
for email := range foundEmails {
entry := plan[email]
updated := *entry.record.ToClient()
if entry.applyExpiry {
updated.ExpiryTime = entry.newExpiry
}
if entry.applyTotal {
updated.TotalGB = entry.newTotal
}
updated.UpdatedAt = nowMs
if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
pushFailed = true
}
}
if !pushFailed {
advancePushedInbound(rt, prevSettings, oldInbound)
}
}
}
}
// Serialize against the traffic poll to avoid the cross-transaction
// lock-order deadlock on inbounds/client_records (runSerializedTx).
txErr := runSerializedTx(func(tx *gorm.DB) error {
@@ -725,6 +685,26 @@ func (s *ClientService) bulkAdjustInboundClients(
res.perEmailSkipped[email] = txErr.Error()
}
}
} else if oldInbound.NodeID != nil && !flowChanged && len(foundEmails) <= nodeBulkPushThreshold {
rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
logger.Warning("BulkAdjust: node runtime lookup after commit failed:", perr)
} else if push {
for email := range foundEmails {
entry := plan[email]
updated := *entry.record.ToClient()
if entry.applyExpiry {
updated.ExpiryTime = entry.newExpiry
}
if entry.applyTotal {
updated.TotalGB = entry.newTotal
}
updated.UpdatedAt = nowMs
if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
}
}
}
}
return res
@@ -980,7 +960,6 @@ func (s *ClientService) bulkDelInboundClients(
}
return res
}
prevSettings := oldInbound.Settings
oldInbound.Settings = string(newSettings)
foundList := make([]string, 0, len(foundEmails))
@@ -1048,56 +1027,6 @@ func (s *ClientService) bulkDelInboundClients(
}
}
if oldInbound.NodeID == nil {
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
res.needRestart = true
} else {
for email := range foundEmails {
if !enableByEmail[email] || !notDepletedByEmail[email] {
continue
}
err1 := rt.RemoveUser(context.Background(), oldInbound, email)
if err1 == nil {
logger.Debug("Client deleted on", rt.Name(), ":", email)
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debug("User is already deleted. Nothing to do more...")
} else {
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
res.needRestart = true
}
}
}
} else {
rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
for email := range foundEmails {
res.perEmailSkipped[email] = perr.Error()
delete(foundEmails, email)
}
} else {
// Large batches collapse into one reconcile push rather than M deletes.
if push && len(foundEmails) > nodeBulkPushThreshold {
push = false
}
if push {
// bulkDelInboundClients only runs for full client deletion
// (BulkDelete), so the node must drop its client record too,
// not just detach from this inbound (#5797).
pushFailed := false
for email := range foundEmails {
if err1 := rt.DeleteClient(context.Background(), email); err1 != nil {
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
pushFailed = true
}
}
if !pushFailed {
advancePushedInbound(rt, prevSettings, oldInbound)
}
}
}
}
// Serialize against the traffic poll to avoid the cross-transaction
// lock-order deadlock on inbounds/client_records (runSerializedTx).
txErr := runSerializedTx(func(tx *gorm.DB) error {
@@ -1122,6 +1051,37 @@ func (s *ClientService) bulkDelInboundClients(
res.perEmailSkipped[email] = txErr.Error()
}
}
} else if oldInbound.NodeID == nil {
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
res.needRestart = true
} else {
for email := range foundEmails {
if !enableByEmail[email] || !notDepletedByEmail[email] {
continue
}
err1 := rt.RemoveUser(context.Background(), oldInbound, email)
if err1 == nil {
logger.Debug("Client deleted on", rt.Name(), ":", email)
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debug("User is already deleted. Nothing to do more...")
} else {
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
res.needRestart = true
}
}
}
} else if len(foundEmails) <= nodeBulkPushThreshold {
rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
logger.Warning("BulkDelete: node runtime lookup after commit failed:", perr)
} else if push {
for email := range foundEmails {
if err1 := rt.DeleteClient(context.Background(), email); err1 != nil {
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
}
}
}
}
return res