merge: item 2 node-deletion cascade
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"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/netsafe"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -512,6 +513,10 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
|
||||
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 {
|
||||
db := database.GetDB()
|
||||
// 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 {
|
||||
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
|
||||
// children (traffic baselines, IP attribution) before the parent node row so
|
||||
// the ordering already matches a future ON DELETE constraint. Delete stays
|
||||
// tolerant of a missing node row so it can still clean up orphaned baselines.
|
||||
// children (agent bindings, baselines, IP attribution) before the parent
|
||||
// row; ordering already matches a future ON DELETE constraint, and Delete
|
||||
// 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 := 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 {
|
||||
return err
|
||||
}
|
||||
@@ -553,6 +567,14 @@ func (s *NodeService) Delete(id int) error {
|
||||
}); err != nil {
|
||||
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, "mem"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user