mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
8ca321138b
Three statement shapes bound one SQL variable per row and failed past
SQLite's limit, which a node can reach by itself with the clients and
inbounds it reports: the depleted-client lookup and disable in
disableInvalidClients (stopping every expiry and quota check), the
whole-slice Saves of renewed inbounds and traffics (stopping renewals),
and Preload("ClientStats") over all inbounds (stopping config builds).
The IN lists are chunked, the Saves go 100 rows at a time, and client
stats are attached in chunks instead of preloaded.
147 lines
4.1 KiB
Go
147 lines
4.1 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
// sqliteMaxVars is a safe ceiling for the number of bind parameters in a
|
|
// single SQL statement. SQLite's SQLITE_MAX_VARIABLE_NUMBER is 999 on builds
|
|
// before 3.32 and 32766 after; staying under 999 keeps queries portable
|
|
// across forks/old binaries and also bounds per-query memory on truly large
|
|
// installs (>32k clients) where even modern SQLite would refuse a single IN.
|
|
const sqliteMaxVars = 900
|
|
|
|
// normalizeSubSortIndex maps omitted/zero to 1; explicit negatives are kept
|
|
// so a primary inbound can sort ahead of the default.
|
|
func normalizeSubSortIndex(v int) int {
|
|
if v == 0 {
|
|
return 1
|
|
}
|
|
return v
|
|
}
|
|
|
|
// uniqueNonEmptyStrings returns a deduplicated copy of in with empty strings
|
|
// removed, preserving the order of first occurrence.
|
|
func uniqueNonEmptyStrings(in []string) []string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(in))
|
|
out := make([]string, 0, len(in))
|
|
for _, v := range in {
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// trimmedUniqueEmails trims each address before deduplicating, so the bulk
|
|
// client operations treat " a@x " and "a@x" as the same row.
|
|
func trimmedUniqueEmails(in []string) []string {
|
|
trimmed := make([]string, 0, len(in))
|
|
for _, e := range in {
|
|
trimmed = append(trimmed, strings.TrimSpace(e))
|
|
}
|
|
return uniqueNonEmptyStrings(trimmed)
|
|
}
|
|
|
|
// uniqueInts returns a deduplicated copy of in, preserving order of first occurrence.
|
|
func uniqueInts(in []int) []int {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[int]struct{}, len(in))
|
|
out := make([]int, 0, len(in))
|
|
for _, v := range in {
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// chunkStrings splits s into consecutive sub-slices of at most size elements.
|
|
// Returns nil for an empty input or non-positive size.
|
|
func chunkStrings(s []string, size int) [][]string {
|
|
if size <= 0 || len(s) == 0 {
|
|
return nil
|
|
}
|
|
out := make([][]string, 0, chunkCount(len(s), size))
|
|
for i := 0; i < len(s); i += size {
|
|
end := min(i+size, len(s))
|
|
out = append(out, s[i:end])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// chunkInts splits s into consecutive sub-slices of at most size elements.
|
|
// Returns nil for an empty input or non-positive size.
|
|
func chunkInts(s []int, size int) [][]int {
|
|
if size <= 0 || len(s) == 0 {
|
|
return nil
|
|
}
|
|
out := make([][]int, 0, chunkCount(len(s), size))
|
|
for i := 0; i < len(s); i += size {
|
|
end := min(i+size, len(s))
|
|
out = append(out, s[i:end])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// chunkCount is ceil(n/size) without the n+size-1 sum, which overflows for a size near MaxInt.
|
|
func chunkCount(n, size int) int {
|
|
count := n / size
|
|
if n%size != 0 {
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
// attachClientStats fills each inbound's ClientStats in chunks: GORM's Preload binds one
|
|
// variable per inbound, and nodes alone can report more inbounds than SQLite binds.
|
|
func attachClientStats(db *gorm.DB, inbounds []*model.Inbound) error {
|
|
byID := make(map[int]*model.Inbound, len(inbounds))
|
|
ids := make([]int, 0, len(inbounds))
|
|
for _, ib := range inbounds {
|
|
byID[ib.Id] = ib
|
|
ids = append(ids, ib.Id)
|
|
}
|
|
for _, batch := range chunkInts(ids, sqliteMaxVars) {
|
|
var stats []xray.ClientTraffic
|
|
if err := db.Where("inbound_id IN ?", batch).Order("id ASC").Find(&stats).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, st := range stats {
|
|
if ib := byID[st.InboundId]; ib != nil {
|
|
ib.ClientStats = append(ib.ClientStats, st)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// saveInChunks saves rows a slice at a time: one slice Save binds every column of every
|
|
// row in a single statement, past SQLite's variable limit once there are enough rows.
|
|
func saveInChunks[T any](tx *gorm.DB, rows []T) error {
|
|
const rowsPerStatement = 100
|
|
for start := 0; start < len(rows); start += rowsPerStatement {
|
|
if err := tx.Save(rows[start:min(start+rowsPerStatement, len(rows))]).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|