Files
l-ui/internal/web/service/node_delete_cascade_test.go
T
Muse 9681dc6f4a fix(node): cascade node deletion to agent bindings and probe rows
Deleting a panel node left its agent_nodes rows and probe.db rows
behind, so deleted nodes kept showing as ghosts and their probe
latency stayed attributed to leftover rows (TYYUN case). Delete now
removes agent rows bound by agent_nodes.node_id inside the existing
panel transaction, then drops each agent's probe rows (meta, traffic,
both history tiers, ping records and ping assignments) and the
public-page snapshot. Agents link by node_id, probe rows by agent row
id: matching probe rows on the node id would strike another node's
data (Oracle-JP: node 33, agent row 20).
2026-10-24 10:05:19 +00:00

193 lines
7.4 KiB
Go

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)
}
}