mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 14:55:02 +03:00
47be06358e
Deleting a client tombstones its email for 90s, so a node snapshot taken before the delete reached the node cannot resurrect it. Client create and bulk create withdrew that tombstone, but a client re-created through InboundService.AddInbound (SyncInbound / syncInboundClients) did not. On a master, re-adding a node inbound with a just-deleted email therefore let the next node merge filter the new client out of the snapshot, unlink it from its inbound and mark it a sync orphan; ReapSyncOrphans then hard-deleted it 15 minutes later and the node lost it. syncInboundClients now records the emails it creates on the serialized transaction, and runSerializedTx withdraws their tombstones only after the commit, still on the writer, so no merge runs in between and a rolled-back create leaves the tombstone standing. The per-call withdraws in Create and bulkCreate are covered by this and removed. Co-authored-by: FZ1010 <FZ1010@users.noreply.github.com>
211 lines
5.0 KiB
Go
211 lines
5.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
trafficWriterQueueSize = 256
|
|
trafficWriterSubmitTimeout = 5 * time.Second
|
|
)
|
|
|
|
type trafficWriteRequest struct {
|
|
apply func() error
|
|
done chan error
|
|
}
|
|
|
|
type serializedTxContextKey struct{}
|
|
|
|
var (
|
|
twMu sync.Mutex
|
|
twQueue chan *trafficWriteRequest
|
|
twCtx context.Context
|
|
twCancel context.CancelFunc
|
|
twDone chan struct{}
|
|
)
|
|
|
|
// StartTrafficWriter spins up the serial writer goroutine. Safe to call again
|
|
// after StopTrafficWriter — each Start/Stop cycle gets fresh channels. The
|
|
// previous sync.Once-based implementation deadlocked after a SIGHUP-driven
|
|
// panel restart: Stop killed the consumer goroutine but Once prevented Start
|
|
// from spawning a new one, so every later submitTrafficWrite blocked forever
|
|
// on <-req.done with no consumer (including the AddTraffic call inside
|
|
// XrayService.GetXrayConfig that runs from startTask).
|
|
func StartTrafficWriter() {
|
|
twMu.Lock()
|
|
defer twMu.Unlock()
|
|
|
|
if twCancel != nil && twDone != nil {
|
|
select {
|
|
case <-twDone:
|
|
clearTrafficWriterState()
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
|
|
twQueue = queue
|
|
twCtx = ctx
|
|
twCancel = cancel
|
|
twDone = done
|
|
|
|
go runTrafficWriter(ctx, queue, done)
|
|
}
|
|
|
|
// StopTrafficWriter cancels the writer context and waits for the goroutine to
|
|
// drain any pending requests before returning. Resets the package state so a
|
|
// subsequent StartTrafficWriter can spawn a fresh consumer.
|
|
func StopTrafficWriter() {
|
|
twMu.Lock()
|
|
cancel := twCancel
|
|
done := twDone
|
|
if cancel == nil || done == nil {
|
|
twMu.Unlock()
|
|
return
|
|
}
|
|
cancel()
|
|
twMu.Unlock()
|
|
|
|
<-done
|
|
|
|
twMu.Lock()
|
|
if twDone == done {
|
|
clearTrafficWriterState()
|
|
}
|
|
twMu.Unlock()
|
|
}
|
|
|
|
func clearTrafficWriterState() {
|
|
twQueue = nil
|
|
twCtx = nil
|
|
twCancel = nil
|
|
twDone = nil
|
|
}
|
|
|
|
func runTrafficWriter(ctx context.Context, queue chan *trafficWriteRequest, done chan struct{}) {
|
|
defer close(done)
|
|
for {
|
|
select {
|
|
case req := <-queue:
|
|
req.done <- safeApply(req.apply)
|
|
case <-ctx.Done():
|
|
for {
|
|
select {
|
|
case req := <-queue:
|
|
req.done <- safeApply(req.apply)
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// runSerializedTx runs fn inside one DB transaction on the shared serial
|
|
// traffic-writer goroutine, so it can never execute concurrently with the
|
|
// @every 5s traffic poll (AddTraffic). Both touch the hot client_traffics and
|
|
// inbounds rows, and they acquire them in opposite order (the poll locks
|
|
// inbounds then client_traffics; an admin client/inbound mutation does the
|
|
// reverse), which Postgres aborts as a deadlock (SQLSTATE 40P01). Routing every
|
|
// such mutation through this single writer removes that contention entirely.
|
|
//
|
|
// Keep network I/O (node pushes) OUT of fn: holding the single writer across a
|
|
// remote node call would stall all traffic accounting for up to the remote
|
|
// timeout. Apply runtime changes after this returns.
|
|
func runSerializedTx(fn func(tx *gorm.DB) error) error {
|
|
return submitTrafficWrite(func() error {
|
|
state := &serializedTx{}
|
|
err := database.GetDB().Transaction(func(tx *gorm.DB) error {
|
|
ctx := context.WithValue(tx.Statement.Context, serializedTxContextKey{}, state)
|
|
return fn(tx.WithContext(ctx))
|
|
})
|
|
if err == nil {
|
|
// Still on the writer, so no node merge runs between the commit and the lift.
|
|
withdrawClientTombstones(state.createdClientEmails...)
|
|
}
|
|
return err
|
|
})
|
|
}
|
|
|
|
// serializedTx carries what may only take effect once its transaction commits.
|
|
type serializedTx struct {
|
|
createdClientEmails []string
|
|
}
|
|
|
|
func serializedTxOf(tx *gorm.DB) *serializedTx {
|
|
if tx == nil || tx.Statement == nil || tx.Statement.Context == nil {
|
|
return nil
|
|
}
|
|
state, _ := tx.Statement.Context.Value(serializedTxContextKey{}).(*serializedTx)
|
|
return state
|
|
}
|
|
|
|
func isSerializedTx(tx *gorm.DB) bool {
|
|
return serializedTxOf(tx) != nil
|
|
}
|
|
|
|
func safeApply(fn func() error) (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = fmt.Errorf("traffic writer panic: %v", r)
|
|
logger.Error(err.Error())
|
|
}
|
|
}()
|
|
return fn()
|
|
}
|
|
|
|
func submitTrafficWrite(fn func() error) error {
|
|
req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
|
|
|
|
twMu.Lock()
|
|
queue := twQueue
|
|
ctx := twCtx
|
|
done := twDone
|
|
if queue == nil || ctx == nil || done == nil {
|
|
twMu.Unlock()
|
|
return safeApply(fn)
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
twMu.Unlock()
|
|
return safeApply(fn)
|
|
default:
|
|
}
|
|
|
|
timer := time.NewTimer(trafficWriterSubmitTimeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case queue <- req:
|
|
twMu.Unlock()
|
|
case <-timer.C:
|
|
twMu.Unlock()
|
|
return errors.New("traffic writer queue full")
|
|
}
|
|
|
|
select {
|
|
case err := <-req.done:
|
|
return err
|
|
case <-done:
|
|
select {
|
|
case err := <-req.done:
|
|
return err
|
|
default:
|
|
return errors.New("traffic writer stopped before write completed")
|
|
}
|
|
}
|
|
}
|