refactor: deprecate int32 (#7025)
* refactor: deprecate int32 * fix(db): reject legacy user quota schemas at startup * fix(quota): enforce wallet bounds and saturating billing conversions * fix(rate-limit): keep count*duration from wrapping int64 * fix: error message
This commit is contained in:
+37
-16
@@ -8,14 +8,24 @@ import (
|
||||
)
|
||||
|
||||
// Quota conversions are centralized here so every billing path shares one
|
||||
// saturation + logging policy. Quota columns (user/token/log) are 32-bit
|
||||
// integers in the database, so an oversized product must clamp to the int32
|
||||
// range instead of wrapping around and turning a charge into a credit.
|
||||
// saturation + logging policy. Single-request charges stay bounded to int32;
|
||||
// top-ups and wallet-priced purchases use a JavaScript-safe 64-bit domain.
|
||||
const (
|
||||
MaxQuota = math.MaxInt32
|
||||
MinQuota = math.MinInt32
|
||||
MaxQuota = math.MaxInt32
|
||||
MinQuota = math.MinInt32
|
||||
MaxWalletQuota = 1<<53 - 1
|
||||
)
|
||||
|
||||
// ValidateWalletQuota enforces the upper bound shared by wallet mutations.
|
||||
// Negative balances remain valid because billing can temporarily overdraw a
|
||||
// wallet; callers that accept credits must apply their own positive check.
|
||||
func ValidateWalletQuota(quota int) error {
|
||||
if quota > MaxWalletQuota {
|
||||
return fmt.Errorf("wallet quota exceeds %d", MaxWalletQuota)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuotaClampKind identifies why a quota conversion had to be saturated.
|
||||
type QuotaClampKind string
|
||||
|
||||
@@ -27,11 +37,11 @@ const (
|
||||
)
|
||||
|
||||
// QuotaClamp describes a single saturation event: a quota conversion whose
|
||||
// input fell outside the representable int32 range (or was NaN) and was
|
||||
// input fell outside its supported range (or was NaN) and was
|
||||
// therefore clamped. It is surfaced to billing callers so the event can be
|
||||
// recorded on the related consume/task log for admin auditing.
|
||||
type QuotaClamp struct {
|
||||
Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal"
|
||||
Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal" | "WalletQuotaFromDecimal"
|
||||
Kind QuotaClampKind `json:"kind"` // "overflow" | "underflow" | "nan"
|
||||
Original float64 `json:"original"` // best-effort pre-clamp value (decimal -> float64 approx)
|
||||
Clamped int `json:"clamped"` // the saturated result actually used
|
||||
@@ -61,23 +71,27 @@ func (c *QuotaClamp) AuditMap() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// saturateQuota converts an already-rounded quota value to int, clamping to
|
||||
// the int32 range. Whenever clamping (what would otherwise be an integer
|
||||
// wraparound) or a NaN fallback is triggered it logs a warning, because in
|
||||
// saturateQuota converts an already-rounded single-request quota to int.
|
||||
// Whenever clamping (what would otherwise be an integer wraparound) or a NaN
|
||||
// fallback is triggered it logs a warning, because in
|
||||
// normal operation a single request never approaches these bounds — hitting
|
||||
// them signals a bug or an abusive request. `op` names the caller. When a
|
||||
// clamp occurs it returns a non-nil *QuotaClamp so callers can additionally
|
||||
// record the event (e.g. on the consume log); the returned pointer is nil for
|
||||
// in-range values.
|
||||
func saturateQuota(value float64, op string) (int, *QuotaClamp) {
|
||||
return saturateQuotaBounded(value, op, MaxQuota, MinQuota)
|
||||
}
|
||||
|
||||
func saturateQuotaBounded(value float64, op string, maxQuota int, minQuota int) (int, *QuotaClamp) {
|
||||
var clamp *QuotaClamp
|
||||
switch {
|
||||
case math.IsNaN(value):
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0}
|
||||
case value >= MaxQuota:
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: MaxQuota}
|
||||
case value <= MinQuota:
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: MinQuota}
|
||||
case value > float64(maxQuota):
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: maxQuota}
|
||||
case value < float64(minQuota):
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: minQuota}
|
||||
default:
|
||||
return int(value), nil
|
||||
}
|
||||
@@ -147,8 +161,15 @@ func QuotaFromDecimalChecked(d decimal.Decimal) (int, *QuotaClamp) {
|
||||
return saturateQuota(f, "QuotaFromDecimal")
|
||||
}
|
||||
|
||||
// QuotaFromDecimalStrict converts an in-range decimal quota and rejects a
|
||||
// value that would otherwise be saturated at the database's int32 boundary.
|
||||
// QuotaFromDecimalStrict converts an in-range single-request quota and rejects
|
||||
// a value that would otherwise be saturated at the int32 boundary.
|
||||
func QuotaFromDecimalStrict(d decimal.Decimal) (int, error) {
|
||||
return strictQuota(QuotaFromDecimalChecked(d))
|
||||
}
|
||||
|
||||
// WalletQuotaFromDecimalStrict converts wallet and top-up values within the
|
||||
// JavaScript-safe integer range, which is also exactly representable by float64.
|
||||
func WalletQuotaFromDecimalStrict(d decimal.Decimal) (int, error) {
|
||||
f, _ := d.Round(0).Float64()
|
||||
return strictQuota(saturateQuotaBounded(f, "WalletQuotaFromDecimal", MaxWalletQuota, -MaxWalletQuota))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
@@ -21,6 +22,7 @@ func TestQuotaFromFloat(t *testing.T) {
|
||||
assert.Equal(t, 42, QuotaFromFloat(42.4))
|
||||
assert.Equal(t, 42, QuotaFromFloat(42.9))
|
||||
assert.Equal(t, -42, QuotaFromFloat(-42.9))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(float64(math.MaxInt32)+42))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(overflowingProduct))
|
||||
assert.Equal(t, MinQuota, QuotaFromFloat(-overflowingProduct))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(math.Inf(1)))
|
||||
@@ -34,6 +36,7 @@ func TestQuotaRound(t *testing.T) {
|
||||
assert.Equal(t, 42, QuotaRound(41.5))
|
||||
assert.Equal(t, 43, QuotaRound(42.5))
|
||||
assert.Equal(t, -43, QuotaRound(-42.5))
|
||||
assert.Equal(t, MaxQuota, QuotaRound(float64(math.MaxInt32)+0.5))
|
||||
assert.Equal(t, MaxQuota, QuotaRound(overflowingProduct))
|
||||
assert.Equal(t, MinQuota, QuotaRound(-overflowingProduct))
|
||||
assert.Equal(t, 0, QuotaRound(math.NaN()))
|
||||
@@ -93,7 +96,7 @@ func TestQuotaFromFloatStrictReturnsTypedClampError(t *testing.T) {
|
||||
assert.ErrorContains(t, err, "QuotaFromFloat")
|
||||
assert.ErrorContains(t, err, "overflow")
|
||||
assert.ErrorContains(t, err, "original=")
|
||||
assert.ErrorContains(t, err, "clamped=2147483647")
|
||||
assert.ErrorContains(t, err, fmt.Sprintf("clamped=%d", MaxQuota))
|
||||
}
|
||||
|
||||
// TestQuotaRoundChecked verifies the rounding entry point reports clamps the
|
||||
@@ -124,3 +127,20 @@ func TestQuotaFromDecimalChecked(t *testing.T) {
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletQuotaFromDecimalStrict(t *testing.T) {
|
||||
quota, err := WalletQuotaFromDecimalStrict(decimal.NewFromInt(4_294_500_000))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4_294_500_000, quota)
|
||||
|
||||
quota, err = WalletQuotaFromDecimalStrict(decimal.NewFromInt(MaxWalletQuota))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, MaxWalletQuota, quota)
|
||||
|
||||
quota, err = WalletQuotaFromDecimalStrict(decimal.NewFromInt(MaxWalletQuota + 1))
|
||||
assert.Zero(t, quota)
|
||||
var clamp *QuotaClamp
|
||||
require.ErrorAs(t, err, &clamp)
|
||||
assert.Equal(t, "WalletQuotaFromDecimal", clamp.Op)
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user