fix(sync): mark node dirty inside the mutation transaction (atomic ConfigDirty) (#5611)

* fix(sync): mark node dirty inside the mutation transaction

ConfigDirty is currently set by MarkNodeDirty AFTER the mutation, on a
separate DB handle outside the mutation's transaction. A crash or error
between the committed change and the mark leaves a committed config
change that never reconciles to the node (silent drift). Add
MarkNodeDirtyTx(tx, id) and call it inside each mutation's transaction so
the dirty mark commits atomically with the change.

* fix(test): initialize DB in TestResolveInboundAddress and group gorm import

Two CI failures on this branch:

- race (-shuffle=on): TestResolveInboundAddress reaches resolveInboundAddress -> configuredPublicHost -> GetSubDomain, which reads the global DB. The test never initialized one, relying on another sub-package test to do so first; under shuffle it ran first and nil-dereferenced gorm. Call initSubDB(t) so it is self-sufficient (empty DB yields an empty subDomain, so the subscriber-host fallback still holds).

- golangci goimports: gorm.io/gorm was grouped with the github.com/mhsanaei/3x-ui local imports in node_dirty_test.go. Move it into the third-party group.
This commit is contained in:
n0ctal
2026-06-28 18:18:28 +05:00
committed by GitHub
parent 2b10808fbd
commit aef35ee0de
7 changed files with 171 additions and 149 deletions
+35 -34
View File
@@ -659,12 +659,14 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
tx.Rollback()
return
}
tx.Commit()
if markDirty && inbound.NodeID != nil {
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
logger.Warning("mark node dirty failed:", dErr)
if dErr := (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID); dErr != nil {
err = dErr
tx.Rollback()
return
}
}
tx.Commit()
}()
// Omit the ClientStats has-many association: GORM's cascade would INSERT
@@ -809,17 +811,20 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
}
}
if err := db.Delete(model.Inbound{}, id).Error; err != nil {
return needRestart, err
}
// Hosts have no hard FK; drop the inbound's hosts alongside it.
if err := db.Where("inbound_id = ?", id).Delete(&model.Host{}).Error; err != nil {
return needRestart, err
}
if markDirty && ib.NodeID != nil {
if dErr := (&NodeService{}).MarkNodeDirty(*ib.NodeID); dErr != nil {
logger.Warning("mark node dirty failed:", dErr)
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(model.Inbound{}, id).Error; err != nil {
return err
}
// Hosts have no hard FK; drop the inbound's hosts alongside it.
if err := tx.Where("inbound_id = ?", id).Delete(&model.Host{}).Error; err != nil {
return err
}
if markDirty && ib.NodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *ib.NodeID)
}
return nil
}); err != nil {
return needRestart, err
}
if !database.IsPostgres() {
var count int64
@@ -902,14 +907,22 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
}
db := database.GetDB()
if err := db.Model(model.Inbound{}).Where("id = ?", id).
Update("enable", enable).Error; err != nil {
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(model.Inbound{}).Where("id = ?", id).
Update("enable", enable).Error; err != nil {
return err
}
if inbound.NodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
}
return nil
}); err != nil {
return false, err
}
inbound.Enable = enable
needRestart := false
rt, push, dirty, perr := s.nodePushPlan(inbound)
rt, push, _, perr := s.nodePushPlan(inbound)
if perr != nil {
return false, perr
}
@@ -923,12 +936,6 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
if push {
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
logger.Warning("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
dirty = true
}
}
if dirty {
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
logger.Warning("mark node dirty failed:", dErr)
}
}
return false, nil
@@ -991,7 +998,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldTagWasAuto := isAutoGeneratedTag(tag, oldInbound.Port, oldInbound.NodeID, oldBits)
needRestart := false
markDirty := false
// Persist the client-stat sync, settings munging, runtime push and inbound
// save as one transaction routed through the serial traffic writer, so it
@@ -1117,13 +1123,10 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Tag = resolvedTag
inbound.Tag = oldInbound.Tag
rt, push, dirty, perr := s.nodePushPlan(oldInbound)
rt, push, _, perr := s.nodePushPlan(oldInbound)
if perr != nil {
return perr
}
if dirty {
markDirty = true
}
if oldInbound.NodeID == nil {
if !push {
needRestart = true
@@ -1152,11 +1155,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
if !inbound.Enable {
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
logger.Warning("Unable to disable inbound on", rt.Name(), ":", err2)
markDirty = true
}
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
logger.Warning("Unable to update inbound on", rt.Name(), ":", err2)
markDirty = true
}
}
@@ -1179,6 +1180,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
if err := s.clientService.SyncInbound(tx, oldInbound.Id, newClients); err != nil {
return err
}
if oldInbound.NodeID != nil {
if err := (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID); err != nil {
return err
}
}
// (Re)generate the Xray config whenever routing was or is now enabled, so
// the egress SOCKS bridge is added, moved, or dropped to match the new
// settings.
@@ -1201,11 +1207,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
needRestart = true
}
}
if markDirty && oldInbound.NodeID != nil {
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
logger.Warning("mark node dirty failed:", dErr)
}
}
return inbound, needRestart, nil
}