Files
3x-ui/internal/database/dialect.go
T
25f2002654 f172bc3f10 fix(node-sync): do not latch node disable when master allows (#6870) (#6876)
* fix(node-sync): do not latch node disable when master allows (#6870)

nodeDisableIsStale trusted any node-reported enable=false whose limits matched
the master's, treating it as a genuine verdict and latching it on the master.
But matching limits no longer prove the verdict: a single stray false
(a depleted snapshot the master later re-enabled, a stale
disableInvalidClients hit, or a push that lagged a quota reset) leaves the
client stuck disabled on the node, and every master tick flips the client's
state again. This is the loop reported in #6870.

Make nodeDisableIsStale a single rule: stale iff the master's own deadline
and quota still permit the client. A genuine combined-quota or expiry
disable (masterLimitsAllowClient == false) still latches, preserving #4917.
Mirror the same rule in ClientTrafficEnableMergeExpr so the SQL UPDATE and
the Go in-memory replay cannot drift.

* fix(node-sync): keep node usage over quota latching a node disable

Dropping the matching-limits latch (#6870) let the master re-enable a
client the node had genuinely counted past its quota whenever the
master's own counters lagged, e.g. after a seeded-at-zero adoption,
reopening #4917. A node's enable=false now stays latched when the
node's own usage already spends the master's quota; otherwise the
master's limits decide, so a stray disable no longer loops.

Restores TestNodeQuotaDisable_SameExpiryStillLatches as it was on main.

---------

Co-authored-by: Alireza Ahmadi <alireza7@gmail.com>
2026-10-23 19:04:46 +02:00

69 lines
2.2 KiB
Go

package database
import "fmt"
// TrafficMax caps every traffic counter safely below math.MaxInt64 (~9.22e18)
// so that one more delta can never overflow int64. SQLite silently promotes an
// overflowing INTEGER to REAL, after which the column no longer scans into the
// Go int64 field and every reader of the table fails (#5762).
const TrafficMax = int64(9_000_000_000_000_000_000)
func ClampedAddExpr(col string) string {
if IsPostgres() {
return fmt.Sprintf("LEAST(%s + ?, %d)", col, TrafficMax)
}
return fmt.Sprintf("MIN(%s + ?, %d)", col, TrafficMax)
}
func JSONClientsFromInbound() string {
if IsPostgres() {
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
}
return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
}
func JSONFieldText(expr, key string) string {
if IsPostgres() {
return fmt.Sprintf("(%s ->> '%s')", expr, key)
}
return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
}
func GreatestExpr(a, b string) string {
if IsPostgres() {
return fmt.Sprintf("GREATEST(%s::bigint, %s::bigint)", a, b)
}
return fmt.Sprintf("MAX(%s, %s)", a, b)
}
// ClientTrafficEnableMergeExpr: placeholders nodeEnable, now, deltaUp, deltaDown,
// nodeUp, nodeDown. Mirrors nodeDisableIsStale (#6228 / #4917 / #6870).
func ClientTrafficEnableMergeExpr() string {
if IsPostgres() {
return `CASE
WHEN ?::boolean THEN enable::boolean
WHEN (expiry_time <= 0 OR expiry_time > CAST(? AS BIGINT))
AND (total <= 0 OR up + ? + down + ? < total)
AND (total <= 0 OR CAST(? AS BIGINT) + CAST(? AS BIGINT) < total) THEN enable::boolean
ELSE false
END`
}
return `CASE
WHEN ? THEN enable
WHEN (expiry_time <= 0 OR expiry_time > CAST(? AS BIGINT))
AND (total <= 0 OR up + ? + down + ? < total)
AND (total <= 0 OR CAST(? AS BIGINT) + CAST(? AS BIGINT) < total) THEN enable
ELSE 0
END`
}
// ClientTrafficExpiryMergeExpr: placeholder nodeExpiry once. Master absolute is
// kept; CAST avoids Postgres int4 inference on ms timestamps.
func ClientTrafficExpiryMergeExpr() string {
return `CASE
WHEN expiry_time > 0 THEN expiry_time
ELSE CAST(? AS BIGINT)
END`
}