Files
3x-ui/internal/web/service/client_sync_orphan.go
T
MHSanaei 8b525b5be6 fix(node): clear a client's orphan mark when it is attached again
A client with an inbound link is not an orphan, but the node sweep's
mark was cleared only at the end of a node merge. An admin attach left
a stale mark, and ReapSyncOrphans deleted the client at its next
detach, traffic and IPs included, without the grace window.

Inserting a link now clears the mark of the clients it links, and the
reaper rereads its candidates inside its writer, so an attach that
commits after the list was read is not reaped.
2026-11-01 13:30:53 +01:00

100 lines
3.4 KiB
Go

package service
import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
// How long a client stays recoverable after a node merge concluded it is gone.
// Attaching it again inside this window, by a merge or an admin, clears the mark.
const syncOrphanReapGrace = 15 * time.Minute
// A traffic row with no clients row behind it is drift the orphan sweep will
// never mark, so it stays on the old delete-immediately path.
func clientRecordExists(tx *gorm.DB, email string) bool {
var n int64
if err := tx.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&n).Error; err != nil {
return false
}
return n > 0
}
func markSyncOrphan(tx *gorm.DB, email string, nodeID int, nowMs int64) error {
if email == "" {
return nil
}
return tx.Model(&model.ClientRecord{}).
Where("email = ? AND sync_orphaned_at = 0", email).
Updates(map[string]any{"sync_orphaned_at": nowMs, "sync_orphaned_by": nodeID}).Error
}
// A client that is attached again is not orphaned, whatever an earlier merge
// concluded — this is what makes a bad merge recoverable instead of fatal.
func clearSyncOrphanMarks(tx *gorm.DB) error {
return tx.Model(&model.ClientRecord{}).
Where("sync_orphaned_at > 0 AND EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
Updates(map[string]any{"sync_orphaned_at": 0, "sync_orphaned_by": 0}).Error
}
func reapableSyncOrphans(tx *gorm.DB, cutoff int64) *gorm.DB {
return tx.Model(&model.ClientRecord{}).
Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff).
Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)")
}
// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and nothing
// attached again since. It is the only path that hard-deletes for that sweep.
func (s *ClientService) ReapSyncOrphans() (int, error) {
db := database.GetDB()
cutoff := time.Now().Add(-syncOrphanReapGrace).UnixMilli()
var emails []string
if err := reapableSyncOrphans(db, cutoff).Pluck("email", &emails).Error; err != nil {
return 0, err
}
if len(emails) == 0 {
return 0, nil
}
reaped := 0
for _, listed := range chunkStrings(emails, sqlInChunk) {
var batch []string
if err := runSerializedTx(func(tx *gorm.DB) error {
// The list was read outside the writer; an attach may have committed since.
if err := reapableSyncOrphans(tx, cutoff).Where("email IN ?", listed).Pluck("email", &batch).Error; err != nil {
return err
}
if len(batch) == 0 {
return nil
}
if err := adjustGroupBaselinesForRemovedTraffic(tx, batch); err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
return err
}
return tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error
}); err != nil {
return reaped, err
}
if len(batch) == 0 {
continue
}
reaped += len(batch)
logger.Infof("reaped %d client(s) confirmed removed on their node", len(batch))
}
return reaped, nil
}