merge: item 2 node-deletion cascade

This commit is contained in:
Muse
2026-10-24 10:09:15 +00:00
2 changed files with 217 additions and 3 deletions
+25 -3
View File
@@ -18,6 +18,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate" "github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
"github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -512,6 +513,10 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
return nil return nil
} }
// deleteProbeDataForAgent drops one agent's probe rows; a variable so
// tests can point the cascade at a scratch probe store.
var deleteProbeDataForAgent = probe.DeleteAgentData
func (s *NodeService) Delete(id int) error { func (s *NodeService) Delete(id int) error {
db := database.GetDB() db := database.GetDB()
// Refuse to delete a node that still owns inbounds: dropping the node row // Refuse to delete a node that still owns inbounds: dropping the node row
@@ -534,11 +539,20 @@ func (s *NodeService) Delete(id int) error {
if err := db.Select("guid").Where("id = ?", id).First(&n).Error; err == nil { if err := db.Select("guid").Where("id = ?", id).First(&n).Error; err == nil {
guid = n.Guid guid = n.Guid
} }
// Bound agents link by agent_nodes.node_id = panel node id, never agent_nodes.id (Oracle-JP: node 33, agent row 20).
// Collected before the transaction so a failed commit never touches probe data.
var agentIDs []int
if err := db.Model(&model.AgentNode{}).Where("node_id = ?", id).Pluck("id", &agentIDs).Error; err != nil {
return err
}
// Delete the node row and its per-node child rows atomically. Remove the // Delete the node row and its per-node child rows atomically. Remove the
// children (traffic baselines, IP attribution) before the parent node row so // children (agent bindings, baselines, IP attribution) before the parent
// the ordering already matches a future ON DELETE constraint. Delete stays // row; ordering already matches a future ON DELETE constraint, and Delete
// tolerant of a missing node row so it can still clean up orphaned baselines. // stays tolerant of a missing node row so it can still clean up orphans.
if err := db.Transaction(func(tx *gorm.DB) error { if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("node_id = ?", id).Delete(&model.AgentNode{}).Error; err != nil {
return err
}
if err := tx.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil { if err := tx.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
return err return err
} }
@@ -553,6 +567,14 @@ func (s *NodeService) Delete(id int) error {
}); err != nil { }); err != nil {
return err return err
} }
// probe.db is a separate file, so its rows go in their own transaction inside DeleteAgentData, keyed by agent row id.
// Best-effort like agent deletion; the public-page snapshot is dropped so the node vanishes at once.
for _, agentID := range agentIDs {
deleteProbeDataForAgent(int64(agentID))
}
if len(agentIDs) > 0 {
InvalidateProbeSnapshot()
}
nodeMetrics.drop(nodeMetricKey(id, "cpu")) nodeMetrics.drop(nodeMetricKey(id, "cpu"))
nodeMetrics.drop(nodeMetricKey(id, "mem")) nodeMetrics.drop(nodeMetricKey(id, "mem"))
return nil return nil
@@ -0,0 +1,192 @@
package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// Node deletion must cascade to the node's own agent bindings and to
// every probe row of those agents, and to nothing else. The key trap:
// agent rows link by node_id (the panel node id), while probe rows
// link by agent row id — matching probe rows on the node id would
// delete a different node's probe data (Oracle-JP: node 33's agent
// row id was 20, so node-id matching would have struck agent 33).
func TestNodeDelete_CascadesAgentAndProbeRows(t *testing.T) {
db := initTrafficTestDB(t)
st, err := probe.Open(filepath.Join(t.TempDir(), "probe.db"))
if err != nil {
t.Fatalf("open probe store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
var probeErr error
oldProbeDelete := deleteProbeDataForAgent
deleteProbeDataForAgent = func(id int64) {
if err := st.DeleteAgentData(id); err != nil && probeErr == nil {
probeErr = err
}
}
t.Cleanup(func() { deleteProbeDataForAgent = oldProbeDelete })
nodeA := &model.Node{Name: "cascade-a"}
nodeB := &model.Node{Name: "cascade-b"}
if err := db.Create(nodeA).Error; err != nil {
t.Fatalf("create node A: %v", err)
}
if err := db.Create(nodeB).Error; err != nil {
t.Fatalf("create node B: %v", err)
}
mkAgent := func(name string, nodeID *int) *model.AgentNode {
ag := &model.AgentNode{TokenHash: "hash-" + name, Name: name, NodeID: nodeID}
if err := db.Create(ag).Error; err != nil {
t.Fatalf("create agent %s: %v", name, err)
}
return ag
}
a1 := mkAgent("cascade-a1", &nodeA.Id)
a2 := mkAgent("cascade-a2", &nodeA.Id)
b1 := mkAgent("cascade-b1", &nodeB.Id)
u1 := mkAgent("cascade-unbound", nil)
if err := db.Create(&model.NodeClientTraffic{NodeId: nodeA.Id, Email: "a@example.com"}).Error; err != nil {
t.Fatalf("seed baseline A: %v", err)
}
if err := db.Create(&model.NodeClientTraffic{NodeId: nodeB.Id, Email: "b@example.com"}).Error; err != nil {
t.Fatalf("seed baseline B: %v", err)
}
seedProbe := func(ag *model.AgentNode, price float64) {
t.Helper()
exec := func(q string, args ...any) {
t.Helper()
if _, err := st.DB().Exec(q, args...); err != nil {
t.Fatalf("seed probe agent %d: %v", ag.Id, err)
}
}
exec(`INSERT INTO probe_meta (agent_id, price) VALUES (?, ?)`, ag.Id, price)
exec(`INSERT INTO probe_traffic (agent_id, total_rx) VALUES (?, 100)`, ag.Id)
exec(`INSERT INTO probe_metric (agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx, tcp, udp, procs) VALUES (?, 1000, 1, 1, 1, 1, 1, 1, 1, 1, 1)`, ag.Id)
exec(`INSERT INTO probe_ping_node (task_id, agent_id) VALUES (7001, ?)`, ag.Id)
exec(`INSERT INTO probe_ping_record (agent_id, ts, task_id, latency) VALUES (?, 1000, 7001, 5)`, ag.Id)
}
if _, err := st.DB().Exec(`INSERT INTO probe_ping_task (id, name, target) VALUES (7001, 'cascade-task', 'example.com')`); err != nil {
t.Fatalf("seed ping task: %v", err)
}
seedProbe(a1, 11)
seedProbe(a2, 22)
seedProbe(b1, 33)
svc := &NodeService{}
if err := svc.Delete(nodeA.Id); err != nil {
t.Fatalf("Delete node A: %v", err)
}
if probeErr != nil {
t.Fatalf("probe cascade: %v", probeErr)
}
countPanel := func(table, where string, args ...any) int64 {
t.Helper()
var n int64
if err := db.Table(table).Where(where, args...).Count(&n).Error; err != nil {
t.Fatalf("count %s: %v", table, err)
}
return n
}
countProbe := func(table string, agentID int) int {
t.Helper()
var n int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE agent_id = ?`, agentID).Scan(&n); err != nil {
t.Fatalf("count probe %s agent %d: %v", table, agentID, err)
}
return n
}
if got := countPanel("nodes", "id = ?", nodeA.Id); got != 0 {
t.Fatalf("node A rows = %d, want 0", got)
}
if got := countPanel("nodes", "id = ?", nodeB.Id); got != 1 {
t.Fatalf("node B rows = %d, want 1", got)
}
if got := countPanel("agent_nodes", "node_id = ?", nodeA.Id); got != 0 {
t.Fatalf("node A agent rows = %d, want 0", got)
}
if got := countPanel("agent_nodes", "id = ?", b1.Id); got != 1 {
t.Fatalf("node B agent row = %d, want 1", got)
}
if got := countPanel("agent_nodes", "id = ?", u1.Id); got != 1 {
t.Fatalf("unbound agent row = %d, want 1", got)
}
if got := countPanel("node_client_traffics", "node_id = ?", nodeA.Id); got != 0 {
t.Fatalf("node A baselines = %d, want 0", got)
}
if got := countPanel("node_client_traffics", "node_id = ?", nodeB.Id); got != 1 {
t.Fatalf("node B baselines = %d, want 1", got)
}
// Deleted agents: every probe table empty. Surviving agent: every
// probe row intact, with its own values (not just a nonzero count).
for _, table := range []string{"probe_meta", "probe_traffic", "probe_metric", "probe_ping_node", "probe_ping_record"} {
for _, ag := range []*model.AgentNode{a1, a2} {
if got := countProbe(table, ag.Id); got != 0 {
t.Fatalf("%s rows for deleted agent %d = %d, want 0", table, ag.Id, got)
}
}
if got := countProbe(table, b1.Id); got != 1 {
t.Fatalf("%s rows for surviving agent %d = %d, want 1", table, b1.Id, got)
}
}
var price float64
if err := st.DB().QueryRow(`SELECT price FROM probe_meta WHERE agent_id = ?`, b1.Id).Scan(&price); err != nil || price != 33 {
t.Fatalf("surviving agent meta price = %v err=%v, want 33", price, err)
}
var taskCnt int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM probe_ping_task WHERE id = 7001`).Scan(&taskCnt); err != nil || taskCnt != 1 {
t.Fatalf("ping task rows = %d err=%v, want 1 (tasks are global, only assignments cascade)", taskCnt, err)
}
}
// A blocked delete (inbounds still attached) must touch neither panel
// agent rows nor probe rows: the probe cascade runs only after the
// panel transaction commits.
func TestNodeDelete_BlockedKeepsAgentAndProbeRows(t *testing.T) {
db := initTrafficTestDB(t)
st, err := probe.Open(filepath.Join(t.TempDir(), "probe.db"))
if err != nil {
t.Fatalf("open probe store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
var probeCalls []int64
oldProbeDelete := deleteProbeDataForAgent
deleteProbeDataForAgent = func(id int64) { probeCalls = append(probeCalls, id) }
t.Cleanup(func() { deleteProbeDataForAgent = oldProbeDelete })
node := &model.Node{Name: "blocked-node"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
ag := &model.AgentNode{TokenHash: "hash-blocked", Name: "blocked-agent", NodeID: &node.Id}
if err := db.Create(ag).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
if _, err := st.DB().Exec(`INSERT INTO probe_meta (agent_id, price) VALUES (?, 44)`, ag.Id); err != nil {
t.Fatalf("seed probe meta: %v", err)
}
createNodeInbound(t, db, node.Id, "blocked-in-443", 443)
svc := &NodeService{}
if err := svc.Delete(node.Id); err == nil {
t.Fatal("Delete should fail while an inbound is still attached")
}
if len(probeCalls) != 0 {
t.Fatalf("probe cascade ran on blocked delete: %v", probeCalls)
}
var nodeCnt, agCnt int64
db.Model(&model.Node{}).Where("id = ?", node.Id).Count(&nodeCnt)
db.Model(&model.AgentNode{}).Where("id = ?", ag.Id).Count(&agCnt)
var metaCnt int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM probe_meta WHERE agent_id = ?`, ag.Id).Scan(&metaCnt); err != nil {
t.Fatalf("count probe meta: %v", err)
}
if nodeCnt != 1 || agCnt != 1 || metaCnt != 1 {
t.Fatalf("after blocked delete: node=%d agent=%d probeMeta=%d, want 1/1/1", nodeCnt, agCnt, metaCnt)
}
}