fix(billing): surface quota saturation events for admin auditing
Thread int32 saturation clamps from tiered settlement and video task recompute into the consume/task logs under admin_info, so oversized or malformed billing inputs stay auditable. Clamp negative audio duration before token conversion and gate the saturation UI markers on admin.
This commit is contained in:
+110
-14
@@ -1,21 +1,117 @@
|
||||
package common
|
||||
|
||||
import "math"
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
// QuotaFromFloat converts a computed quota value to int with saturation.
|
||||
// Quota products can include user-controlled multipliers (image n, video
|
||||
// seconds, resolution ratios); an oversized product must never wrap around
|
||||
// and turn a charge into a credit. The bound is int32 because quota columns
|
||||
// (user/token/log) are 32-bit integers in the database.
|
||||
func QuotaFromFloat(value float64) int {
|
||||
if math.IsNaN(value) {
|
||||
return 0
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// 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.
|
||||
const (
|
||||
MaxQuota = math.MaxInt32
|
||||
MinQuota = math.MinInt32
|
||||
)
|
||||
|
||||
// Clamp kinds reported by QuotaClamp.Kind.
|
||||
const (
|
||||
QuotaClampOverflow = "overflow"
|
||||
QuotaClampUnderflow = "underflow"
|
||||
QuotaClampNaN = "nan"
|
||||
)
|
||||
|
||||
// QuotaClamp describes a single saturation event: a quota conversion whose
|
||||
// input fell outside the representable int32 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"
|
||||
Kind string `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
|
||||
}
|
||||
|
||||
// AuditMap renders the clamp as the marker stored under a log's
|
||||
// admin_info.quota_saturation. Centralized here so every billing path (consume
|
||||
// logs, task billing logs, task compensation logs) records the same shape.
|
||||
func (c *QuotaClamp) AuditMap() map[string]interface{} {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if value >= math.MaxInt32 {
|
||||
return math.MaxInt32
|
||||
return map[string]interface{}{
|
||||
"op": c.Op,
|
||||
"kind": c.Kind,
|
||||
"original": c.Original,
|
||||
"clamped": c.Clamped,
|
||||
}
|
||||
if value <= math.MinInt32 {
|
||||
return math.MinInt32
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
switch {
|
||||
case math.IsNaN(value):
|
||||
SysError(fmt.Sprintf("quota conversion (%s) received NaN, falling back to 0", op))
|
||||
return 0, &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0}
|
||||
case value >= MaxQuota:
|
||||
SysError(fmt.Sprintf("quota conversion (%s) overflow: %g exceeds max quota, clamped to %d", op, value, MaxQuota))
|
||||
return MaxQuota, &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: MaxQuota}
|
||||
case value <= MinQuota:
|
||||
SysError(fmt.Sprintf("quota conversion (%s) underflow: %g below min quota, clamped to %d", op, value, MinQuota))
|
||||
return MinQuota, &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: MinQuota}
|
||||
default:
|
||||
return int(value), nil
|
||||
}
|
||||
return int(value)
|
||||
}
|
||||
|
||||
// QuotaFromFloat converts a computed quota value to int, truncating toward
|
||||
// zero, with saturation. Use for float products of prices, ratios, and
|
||||
// user-controlled multipliers (image n, video seconds, resolution ratios).
|
||||
func QuotaFromFloat(value float64) int {
|
||||
quota, _ := QuotaFromFloatChecked(value)
|
||||
return quota
|
||||
}
|
||||
|
||||
// QuotaFromFloatChecked is QuotaFromFloat but also returns a non-nil
|
||||
// *QuotaClamp when the value was clamped, so billing callers can audit it.
|
||||
func QuotaFromFloatChecked(value float64) (int, *QuotaClamp) {
|
||||
return saturateQuota(value, "QuotaFromFloat")
|
||||
}
|
||||
|
||||
// QuotaRound converts a float64 quota value to int using half-away-from-zero
|
||||
// rounding, with saturation. Every tiered billing path (pre-consume,
|
||||
// settlement, breakdown validation, log fields) MUST use this to avoid +-1
|
||||
// discrepancies.
|
||||
func QuotaRound(value float64) int {
|
||||
quota, _ := QuotaRoundChecked(value)
|
||||
return quota
|
||||
}
|
||||
|
||||
// QuotaRoundChecked is QuotaRound but also returns a non-nil *QuotaClamp when
|
||||
// the value was clamped, so billing callers can audit it.
|
||||
func QuotaRoundChecked(value float64) (int, *QuotaClamp) {
|
||||
return saturateQuota(math.Round(value), "QuotaRound")
|
||||
}
|
||||
|
||||
// QuotaFromDecimal converts a computed quota decimal to int with saturation.
|
||||
// The decimal is rounded (half away from zero) before conversion.
|
||||
func QuotaFromDecimal(d decimal.Decimal) int {
|
||||
quota, _ := QuotaFromDecimalChecked(d)
|
||||
return quota
|
||||
}
|
||||
|
||||
// QuotaFromDecimalChecked is QuotaFromDecimal but also returns a non-nil
|
||||
// *QuotaClamp when the value was clamped, so billing callers can audit it.
|
||||
func QuotaFromDecimalChecked(d decimal.Decimal) (int, *QuotaClamp) {
|
||||
f, _ := d.Round(0).Float64()
|
||||
return saturateQuota(f, "QuotaFromDecimal")
|
||||
}
|
||||
|
||||
@@ -4,19 +4,105 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// 2000 quota per call * n=18446744073686646784 overflows int64; the constant
|
||||
// below reproduces that oversized product for the saturation checks.
|
||||
const overflowingProduct = 2000 * 1.8446744073686647e19
|
||||
|
||||
// TestQuotaFromFloat guards the billing invariant that oversized quota
|
||||
// products (e.g. price multiplied by a huge user-supplied count) saturate
|
||||
// instead of wrapping into a negative charge (credit).
|
||||
// instead of wrapping into a negative charge (credit). QuotaFromFloat
|
||||
// truncates toward zero.
|
||||
func TestQuotaFromFloat(t *testing.T) {
|
||||
assert.Equal(t, 42, QuotaFromFloat(42.4))
|
||||
assert.Equal(t, -42, QuotaFromFloat(-42.4))
|
||||
// 2000 quota per call * n=18446744073686646784 overflows int64.
|
||||
assert.Equal(t, math.MaxInt32, QuotaFromFloat(2000*1.8446744073686647e19))
|
||||
assert.Equal(t, math.MinInt32, QuotaFromFloat(-2000*1.8446744073686647e19))
|
||||
assert.Equal(t, math.MaxInt32, QuotaFromFloat(math.Inf(1)))
|
||||
assert.Equal(t, math.MinInt32, QuotaFromFloat(math.Inf(-1)))
|
||||
assert.Equal(t, 42, QuotaFromFloat(42.9))
|
||||
assert.Equal(t, -42, QuotaFromFloat(-42.9))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(overflowingProduct))
|
||||
assert.Equal(t, MinQuota, QuotaFromFloat(-overflowingProduct))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(math.Inf(1)))
|
||||
assert.Equal(t, MinQuota, QuotaFromFloat(math.Inf(-1)))
|
||||
assert.Equal(t, 0, QuotaFromFloat(math.NaN()))
|
||||
}
|
||||
|
||||
// TestQuotaRound checks half-away-from-zero rounding with the same
|
||||
// saturation policy.
|
||||
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(overflowingProduct))
|
||||
assert.Equal(t, MinQuota, QuotaRound(-overflowingProduct))
|
||||
assert.Equal(t, 0, QuotaRound(math.NaN()))
|
||||
}
|
||||
|
||||
// TestQuotaFromDecimal checks the decimal entry point rounds and saturates
|
||||
// consistently with the float variants.
|
||||
func TestQuotaFromDecimal(t *testing.T) {
|
||||
assert.Equal(t, 43, QuotaFromDecimal(decimal.NewFromFloat(42.5)))
|
||||
assert.Equal(t, 42, QuotaFromDecimal(decimal.NewFromFloat(41.7)))
|
||||
assert.Equal(t, MaxQuota, QuotaFromDecimal(decimal.NewFromInt(2000).Mul(decimal.NewFromFloat(1.8446744073686647e19))))
|
||||
assert.Equal(t, MinQuota, QuotaFromDecimal(decimal.NewFromInt(-2000).Mul(decimal.NewFromFloat(1.8446744073686647e19))))
|
||||
}
|
||||
|
||||
// TestQuotaFromFloatChecked verifies the clamp descriptor is nil in range and
|
||||
// carries the correct kind/clamped value on saturation, so billing callers can
|
||||
// audit the event.
|
||||
func TestQuotaFromFloatChecked(t *testing.T) {
|
||||
quota, clamp := QuotaFromFloatChecked(42.9)
|
||||
assert.Equal(t, 42, quota)
|
||||
assert.Nil(t, clamp)
|
||||
|
||||
quota, clamp = QuotaFromFloatChecked(overflowingProduct)
|
||||
assert.Equal(t, MaxQuota, quota)
|
||||
if assert.NotNil(t, clamp) {
|
||||
assert.Equal(t, "QuotaFromFloat", clamp.Op)
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
assert.Equal(t, MaxQuota, clamp.Clamped)
|
||||
}
|
||||
|
||||
quota, clamp = QuotaFromFloatChecked(-overflowingProduct)
|
||||
assert.Equal(t, MinQuota, quota)
|
||||
if assert.NotNil(t, clamp) {
|
||||
assert.Equal(t, QuotaClampUnderflow, clamp.Kind)
|
||||
assert.Equal(t, MinQuota, clamp.Clamped)
|
||||
}
|
||||
|
||||
quota, clamp = QuotaFromFloatChecked(math.NaN())
|
||||
assert.Equal(t, 0, quota)
|
||||
if assert.NotNil(t, clamp) {
|
||||
assert.Equal(t, QuotaClampNaN, clamp.Kind)
|
||||
assert.Equal(t, 0, clamp.Clamped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaRoundChecked verifies the rounding entry point reports clamps the
|
||||
// same way.
|
||||
func TestQuotaRoundChecked(t *testing.T) {
|
||||
quota, clamp := QuotaRoundChecked(42.5)
|
||||
assert.Equal(t, 43, quota)
|
||||
assert.Nil(t, clamp)
|
||||
|
||||
quota, clamp = QuotaRoundChecked(overflowingProduct)
|
||||
assert.Equal(t, MaxQuota, quota)
|
||||
if assert.NotNil(t, clamp) {
|
||||
assert.Equal(t, "QuotaRound", clamp.Op)
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaFromDecimalChecked verifies the decimal entry point reports clamps.
|
||||
func TestQuotaFromDecimalChecked(t *testing.T) {
|
||||
quota, clamp := QuotaFromDecimalChecked(decimal.NewFromFloat(41.7))
|
||||
assert.Equal(t, 42, quota)
|
||||
assert.Nil(t, clamp)
|
||||
|
||||
quota, clamp = QuotaFromDecimalChecked(decimal.NewFromInt(2000).Mul(decimal.NewFromFloat(1.8446744073686647e19)))
|
||||
assert.Equal(t, MaxQuota, quota)
|
||||
if assert.NotNil(t, clamp) {
|
||||
assert.Equal(t, "QuotaFromDecimal", clamp.Op)
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user