Files
3x-ui/internal/web/service/client_edit_node_dirty_test.go
T
Sanaei f2cf589947 fix(node): flag every hosting node before a client edit applies
A client edit fans out one transaction per inbound, and each one renames the
single shared clients row but calls MarkNodeDirtyTx for only its OWN node. So
between the first and the last commit the record already carries the new email
while every other node hosting that client is still config_dirty = false.

setRemoteTrafficLocked gates the snapshot merge on that flag, so a merge landing
in the gap is accepted, sees a pre-rename snapshot, finds no record for the old
email and inserts one through syncInboundClients' CreateInBatches — the only
place in the panel that creates a client record. The ghost is never in any later
merge's perInboundOld, so markSyncOrphan never fires and ReapSyncOrphans never
collects it: the operator is left with a permanent second client under the old
name. The same stale merge reverts an expiry-only edit instead of duplicating it.

Mark every node hosting the client dirty in one serialized write before the
fanout starts, so a merge queued behind it skips the node instead of merging a
half-applied edit. The nodes were going to be marked by their own applies
anyway; doing it up front only moves it earlier, and a client on local-only
inbounds never reaches the writer at all.

The set is the client's FULL attachment list, taken before the inboundIds
filter narrows it: the rename rewrites the one shared record, so an inbound the
filter excluded goes stale too.

Two tests, both red without the change. The first pins the ordering rather than
the end state — it reads the watched node's flag from inside another inbound's
push, so moving the marking after the fanout turns it red. The second pins that
the filtered path still covers the excluded node.

This narrows the window rather than closing it everywhere. A reconcile tick can
still clear the flag mid-fanout, and on a filtered edit the excluded inbound
keeps the old email in its settings for good, so its next merge duplicates
again. The case-drift path — a node reporting another case of a known email —
is untouched and still duplicates.
2026-09-07 01:51:18 +02:00

122 lines
4.3 KiB
Go

package service
import (
"context"
"sync/atomic"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// dirtyProbeRuntime reads a node's config_dirty flag at the moment an inbound
// pushes, i.e. while the edit's other inbounds are still unapplied.
type dirtyProbeRuntime struct {
fakeNodeRuntime
watch int
sawSet atomic.Bool
probed atomic.Bool
}
func (d *dirtyProbeRuntime) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, c model.Client) error {
var node model.Node
if err := database.GetDB().Where("id = ?", d.watch).First(&node).Error; err == nil {
d.sawSet.Store(node.ConfigDirty)
d.probed.Store(true)
}
return d.fakeNodeRuntime.UpdateUser(ctx, ib, oldEmail, c)
}
// TestEditFlagsOtherNodesBeforeApplying pins the ordering: every node is flagged
// BEFORE any inbound applies, so no merge can slot into the fanout gap.
func TestEditFlagsOtherNodesBeforeApplying(t *testing.T) {
setupBulkDB(t)
startSerializedWriter(t)
mgr := useTestRuntimeManager(t)
db := database.GetDB()
const uuid = "eeeeeeee-1111-2222-3333-444444444444"
probe := &dirtyProbeRuntime{}
ids := fanoutNodeInbounds(t, mgr, probe, 2, 47200)
// The watched node is the one whose apply is made to fail, so nothing but
// the up-front marking can have flagged it when the other inbound pushes.
var victim model.Inbound
if err := db.Where("id = ?", ids[1]).First(&victim).Error; err != nil {
t.Fatalf("read inbound %d: %v", ids[1], err)
}
probe.watch = *victim.NodeID
if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
Client: model.Client{Email: "carol", ID: uuid, SubID: "sub-carol", Enable: true},
InboundIds: ids,
}); err != nil {
t.Fatalf("seed Create: %v", err)
}
if err := db.Model(&model.Inbound{}).Where("id = ?", ids[1]).
Update("settings", `{"clients":`).Error; err != nil {
t.Fatalf("corrupt inbound %d: %v", ids[1], err)
}
if err := db.Model(model.Node{}).Where("1 = 1").
Update("config_dirty", false).Error; err != nil {
t.Fatalf("clear config_dirty: %v", err)
}
rec := lookupClientRecord(t, "carol")
if _, err := (&ClientService{}).Update(&InboundService{}, rec.Id, model.Client{
Email: "carol-renamed", ID: uuid, SubID: "sub-carol", Enable: true,
}, 0); err == nil {
t.Fatal("Update on a corrupted inbound should report the failure")
}
if !probe.probed.Load() {
t.Fatal("the healthy inbound never pushed, so the ordering was never observed")
}
if !probe.sawSet.Load() {
t.Fatal("a node was still clean while another inbound of the same edit was applying: a snapshot merge in that gap would resurrect the pre-edit email")
}
}
// TestEditFlagsNodesOutsideTheInboundFilter pins that the marking covers the
// client's whole attachment set, not just the inbounds the filter applies.
func TestEditFlagsNodesOutsideTheInboundFilter(t *testing.T) {
setupBulkDB(t)
startSerializedWriter(t)
mgr := useTestRuntimeManager(t)
db := database.GetDB()
const uuid = "ffffffff-1111-2222-3333-444444444444"
ids := fanoutNodeInbounds(t, mgr, &fakeNodeRuntime{}, 2, 47300)
if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
Client: model.Client{Email: "dave", ID: uuid, SubID: "sub-dave", Enable: true},
InboundIds: ids,
}); err != nil {
t.Fatalf("seed Create: %v", err)
}
if err := db.Model(model.Node{}).Where("1 = 1").
Update("config_dirty", false).Error; err != nil {
t.Fatalf("clear config_dirty: %v", err)
}
// Edit scoped to the first inbound only; the second one's node still holds
// the old email once the shared record is renamed.
rec := lookupClientRecord(t, "dave")
if _, err := (&ClientService{}).Update(&InboundService{}, rec.Id, model.Client{
Email: "dave-renamed", ID: uuid, SubID: "sub-dave", Enable: true,
}, 0, ids[0]); err != nil {
t.Fatalf("filtered Update: %v", err)
}
var excluded model.Inbound
if err := db.Where("id = ?", ids[1]).First(&excluded).Error; err != nil {
t.Fatalf("read inbound %d: %v", ids[1], err)
}
var node model.Node
if err := db.Where("id = ?", *excluded.NodeID).First(&node).Error; err != nil {
t.Fatalf("read node %d: %v", *excluded.NodeID, err)
}
if !node.ConfigDirty {
t.Fatal("a node left out of the inboundIds filter stayed clean after the shared record was renamed, so its stale snapshot would be merged")
}
}