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
+40
View File
@@ -1,8 +1,11 @@
package service
import (
"errors"
"testing"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
@@ -145,6 +148,43 @@ func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
}
}
func TestMarkNodeDirtyTxRollsBackWithTransaction(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
node := &model.Node{Name: "n3", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
nodeSvc := NodeService{}
rollbackErr := errors.New("force rollback")
if err := db.Transaction(func(tx *gorm.DB) error {
if err := nodeSvc.MarkNodeDirtyTx(tx, node.Id); err != nil {
return err
}
return rollbackErr
}); !errors.Is(err, rollbackErr) {
t.Fatalf("rollback tx: got %v want %v", err, rollbackErr)
}
if _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id); err != nil {
t.Fatalf("NodeSyncState after rollback: %v", err)
} else if dirty {
t.Fatal("dirty flag escaped a rolled-back transaction")
}
if err := db.Transaction(func(tx *gorm.DB) error {
return nodeSvc.MarkNodeDirtyTx(tx, node.Id)
}); err != nil {
t.Fatalf("commit tx: %v", err)
}
if _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id); err != nil {
t.Fatalf("NodeSyncState after commit: %v", err)
} else if !dirty {
t.Fatal("dirty flag should commit with its transaction")
}
}
// Editing a node must mark it config-dirty so the next traffic-sync tick
// reconciles (pushes the panel's inbounds to the remote) before pulling a
// snapshot. Without the dirty flag, re-pointing a node to a fresh server