327 lines
6.8 KiB
Go
327 lines
6.8 KiB
Go
// Package quota implements a persisted, long-term traffic-volume limiter that
|
|
// can be shared across services by name. Unlike the rate limiters in
|
|
// limiter/traffic and limiter/rate, it accumulates total bytes within a window
|
|
// [startsAt, expiresAt) and stops the referencing service(s) once a byte limit
|
|
// is reached. Enforcement is fail-open outside the window.
|
|
package quota
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/go-gost/core/logger"
|
|
xlogger "github.com/go-gost/x/logger"
|
|
)
|
|
|
|
var ErrQuotaExceeded = errors.New("quota: traffic limit reached")
|
|
|
|
const defaultFlushInterval = 10 * time.Second
|
|
|
|
type Direction int
|
|
|
|
const (
|
|
DirectionTotal Direction = iota
|
|
DirectionIn
|
|
DirectionOut
|
|
)
|
|
|
|
func (d Direction) String() string {
|
|
switch d {
|
|
case DirectionIn:
|
|
return "in"
|
|
case DirectionOut:
|
|
return "out"
|
|
default:
|
|
return "total"
|
|
}
|
|
}
|
|
|
|
// Options seeds a Limiter. Limit and the window are config-authoritative; a
|
|
// persisted counter is restored only for a matching window (see NewLimiter).
|
|
type Options struct {
|
|
Limit uint64
|
|
StartsAt time.Time
|
|
ExpiresAt time.Time
|
|
Direction Direction
|
|
Flush time.Duration
|
|
Store Store
|
|
Logger logger.Logger
|
|
}
|
|
|
|
// Update overwrites runtime state; a nil field is left unchanged.
|
|
type Update struct {
|
|
Used *uint64
|
|
Limit *uint64
|
|
StartsAt *time.Time
|
|
ExpiresAt *time.Time
|
|
}
|
|
|
|
type Snapshot struct {
|
|
Used uint64
|
|
Limit uint64
|
|
StartsAtUnix int64
|
|
ExpiresAtUnix int64
|
|
Active bool
|
|
Expired bool
|
|
Blocked bool
|
|
Direction string
|
|
}
|
|
|
|
type Limiter struct {
|
|
name string
|
|
direction Direction
|
|
flush time.Duration
|
|
store Store
|
|
log logger.Logger
|
|
|
|
used atomic.Uint64
|
|
limit atomic.Uint64
|
|
startsAt atomic.Int64 // unixnano; 0 = unset
|
|
expiresAt atomic.Int64 // unixnano; 0 = unset
|
|
blocked atomic.Bool
|
|
dirty atomic.Bool
|
|
closed atomic.Bool
|
|
|
|
mu sync.Mutex
|
|
waitCh chan struct{} // closed+replaced to broadcast a state change
|
|
|
|
closeOnce sync.Once
|
|
done chan struct{}
|
|
}
|
|
|
|
func NewLimiter(name string, opts Options) *Limiter {
|
|
l := &Limiter{
|
|
name: name,
|
|
direction: opts.Direction,
|
|
flush: opts.Flush,
|
|
store: opts.Store,
|
|
log: opts.Logger,
|
|
waitCh: make(chan struct{}),
|
|
done: make(chan struct{}),
|
|
}
|
|
if l.flush <= 0 {
|
|
l.flush = defaultFlushInterval
|
|
}
|
|
if l.log == nil {
|
|
l.log = xlogger.Nop()
|
|
}
|
|
|
|
l.limit.Store(opts.Limit)
|
|
sa := unixNanoOrZero(opts.StartsAt)
|
|
ea := unixNanoOrZero(opts.ExpiresAt)
|
|
l.startsAt.Store(sa)
|
|
l.expiresAt.Store(ea)
|
|
|
|
// Restore the counter only within the same window: a changed window (a new
|
|
// period pushed via config) starts fresh.
|
|
if l.store != nil {
|
|
if rec, ok, err := l.store.Load(name); err != nil {
|
|
l.log.Warnf("quota: load %s: %v", name, err)
|
|
} else if ok && rec.StartsAt == sa && rec.ExpiresAt == ea {
|
|
l.used.Store(rec.Used)
|
|
}
|
|
}
|
|
|
|
l.blocked.Store(l.enforcing(time.Now()) && l.used.Load() >= l.limit.Load())
|
|
go l.run()
|
|
return l
|
|
}
|
|
|
|
func (l *Limiter) active(now time.Time) bool {
|
|
n := now.UnixNano()
|
|
if sa := l.startsAt.Load(); sa != 0 && n < sa {
|
|
return false
|
|
}
|
|
if ea := l.expiresAt.Load(); ea != 0 && n >= ea {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (l *Limiter) enforcing(now time.Time) bool {
|
|
return l.limit.Load() > 0 && l.active(now)
|
|
}
|
|
|
|
func (l *Limiter) Blocked() bool {
|
|
if l.closed.Load() || !l.blocked.Load() {
|
|
return false
|
|
}
|
|
return l.enforcing(time.Now())
|
|
}
|
|
|
|
func (l *Limiter) AddIn(n int) { l.add(int64(n), DirectionIn) }
|
|
func (l *Limiter) AddOut(n int) { l.add(int64(n), DirectionOut) }
|
|
|
|
func (l *Limiter) add(n int64, dir Direction) {
|
|
if n <= 0 || l.closed.Load() {
|
|
return
|
|
}
|
|
switch l.direction {
|
|
case DirectionIn:
|
|
if dir != DirectionIn {
|
|
return
|
|
}
|
|
case DirectionOut:
|
|
if dir != DirectionOut {
|
|
return
|
|
}
|
|
}
|
|
if !l.active(time.Now()) {
|
|
return
|
|
}
|
|
used := l.used.Add(uint64(n))
|
|
l.dirty.Store(true)
|
|
if lim := l.limit.Load(); lim > 0 && used >= lim {
|
|
l.block()
|
|
}
|
|
}
|
|
|
|
// block is hot-path: no lock, no I/O, no wakeup (parking only happens on the
|
|
// next Accept).
|
|
func (l *Limiter) block() {
|
|
if l.blocked.CompareAndSwap(false, true) {
|
|
l.log.Warnf("quota: %s reached limit %d bytes, stopping", l.name, l.limit.Load())
|
|
}
|
|
}
|
|
|
|
// WaitChan must be fetched before checking Blocked to avoid a missed wakeup.
|
|
func (l *Limiter) WaitChan() <-chan struct{} {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.waitCh
|
|
}
|
|
|
|
func (l *Limiter) notify() {
|
|
l.mu.Lock()
|
|
close(l.waitCh)
|
|
l.waitCh = make(chan struct{})
|
|
l.mu.Unlock()
|
|
}
|
|
|
|
func (l *Limiter) Update(u Update) {
|
|
if u.Used != nil {
|
|
l.used.Store(*u.Used)
|
|
}
|
|
if u.Limit != nil {
|
|
l.limit.Store(*u.Limit)
|
|
}
|
|
if u.StartsAt != nil {
|
|
l.startsAt.Store(unixNanoOrZero(*u.StartsAt))
|
|
}
|
|
if u.ExpiresAt != nil {
|
|
l.expiresAt.Store(unixNanoOrZero(*u.ExpiresAt))
|
|
}
|
|
l.reevaluate()
|
|
}
|
|
|
|
func (l *Limiter) reevaluate() {
|
|
now := time.Now()
|
|
shouldBlock := l.enforcing(now) && l.used.Load() >= l.limit.Load()
|
|
l.blocked.Store(shouldBlock)
|
|
if !shouldBlock {
|
|
l.notify()
|
|
}
|
|
l.flushNow()
|
|
}
|
|
|
|
func (l *Limiter) Snapshot() Snapshot {
|
|
now := time.Now()
|
|
ea := l.expiresAt.Load()
|
|
return Snapshot{
|
|
Used: l.used.Load(),
|
|
Limit: l.limit.Load(),
|
|
StartsAtUnix: nanoToSec(l.startsAt.Load()),
|
|
ExpiresAtUnix: nanoToSec(ea),
|
|
Active: l.active(now),
|
|
Expired: ea != 0 && now.UnixNano() >= ea,
|
|
Blocked: l.Blocked(),
|
|
Direction: l.direction.String(),
|
|
}
|
|
}
|
|
|
|
func (l *Limiter) run() {
|
|
flushT := time.NewTicker(l.flush)
|
|
defer flushT.Stop()
|
|
boundT := time.NewTicker(time.Second)
|
|
defer boundT.Stop()
|
|
|
|
wasActive := l.active(time.Now())
|
|
|
|
for {
|
|
select {
|
|
case <-l.done:
|
|
return
|
|
|
|
case <-flushT.C:
|
|
if l.dirty.Swap(false) {
|
|
l.persist()
|
|
}
|
|
|
|
case <-boundT.C:
|
|
now := time.Now()
|
|
act := l.active(now)
|
|
if act == wasActive {
|
|
continue
|
|
}
|
|
wasActive = act
|
|
if act {
|
|
l.blocked.Store(l.limit.Load() > 0 && l.used.Load() >= l.limit.Load())
|
|
} else {
|
|
l.blocked.Store(false)
|
|
}
|
|
l.notify()
|
|
l.persist()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (l *Limiter) flushNow() {
|
|
l.dirty.Store(false)
|
|
l.persist()
|
|
}
|
|
|
|
func (l *Limiter) persist() {
|
|
if l.store == nil {
|
|
return
|
|
}
|
|
rec := Record{
|
|
Used: l.used.Load(),
|
|
Limit: l.limit.Load(),
|
|
StartsAt: l.startsAt.Load(),
|
|
ExpiresAt: l.expiresAt.Load(),
|
|
UpdatedAt: time.Now().UnixNano(),
|
|
}
|
|
if err := l.store.Save(l.name, rec); err != nil {
|
|
l.log.Warnf("quota: save %s: %v", l.name, err)
|
|
}
|
|
}
|
|
|
|
// Close makes the limiter inert (Blocked false, counting a no-op) so deleting a
|
|
// shared quota releases the referencing services instead of tearing them down.
|
|
// Called by the registry on Unregister.
|
|
func (l *Limiter) Close() error {
|
|
l.closeOnce.Do(func() {
|
|
l.closed.Store(true)
|
|
close(l.done)
|
|
l.flushNow()
|
|
l.notify()
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func unixNanoOrZero(t time.Time) int64 {
|
|
if t.IsZero() {
|
|
return 0
|
|
}
|
|
return t.UnixNano()
|
|
}
|
|
|
|
func nanoToSec(n int64) int64 {
|
|
if n == 0 {
|
|
return 0
|
|
}
|
|
return n / int64(time.Second)
|
|
}
|