// Package limiter is the token bucket from exercise 03 turned into a keyed // store suitable for a shared rate-limiter service: many buckets addressed by // a string key (tenant, client id, API key), sharded like exercise 09, with // eviction of idle buckets so the map does not grow forever. package limiter import ( "hash/fnv" "sync" "sync/atomic" "time" ) // ---- Bucket: a single token bucket (exercise 03) ---- // Bucket is a lazily refilled token bucket. Safe for concurrent use. type Bucket struct { mu sync.Mutex tokens float64 max float64 rate float64 // tokens per second last time.Time } // NewBucket returns a full bucket allowing `rate` tokens/s with bursts up to `burst`. func NewBucket(rate float64, burst int) *Bucket { return &Bucket{tokens: float64(burst), max: float64(burst), rate: rate, last: time.Now()} } // Decision is the outcome of an AllowN call. type Decision struct { Allowed bool Remaining float64 // tokens left after this decision Limit int // burst (bucket capacity) Rate float64 // refill rate, tokens/s RetryAfter time.Duration // when denied: how long until n tokens would be available } // AllowN tries to take n tokens. Non-blocking. func (b *Bucket) AllowN(n int) Decision { b.mu.Lock() defer b.mu.Unlock() // 1. lazy refill, capped at max; `last` advances on every call now := time.Now() b.tokens += now.Sub(b.last).Seconds() * b.rate if b.tokens > b.max { b.tokens = b.max } b.last = now d := Decision{Limit: int(b.max), Rate: b.rate} // 2. consume or compute how long until we could if b.tokens >= float64(n) { b.tokens -= float64(n) d.Allowed = true } else if b.rate > 0 { d.RetryAfter = time.Duration((float64(n) - b.tokens) / b.rate * float64(time.Second)) } d.Remaining = b.tokens return d } // idleSince reports the last time the bucket was touched and whether it is // full (i.e. nothing would be lost by forgetting it). func (b *Bucket) idleSince() (time.Time, bool) { b.mu.Lock() defer b.mu.Unlock() // A bucket untouched for `max/rate` seconds is full again by definition, // so idle time alone decides; we don't need to refill here. return b.last, true } // ---- Store: many buckets by key (exercise 09's sharding) ---- const shards = 16 type shard struct { mu sync.Mutex m map[string]*Bucket } // Limit is a (rate, burst) pair. type Limit struct { Rate float64 Burst int } // Store holds one Bucket per key, created lazily with the default Limit or a // per-key override. type Store struct { def Limit overrides map[string]Limit // read-only after construction shards [shards]shard allowed atomic.Int64 denied atomic.Int64 } // NewStore creates a store; unknown keys get `def`, keys in `overrides` get theirs. func NewStore(def Limit, overrides map[string]Limit) *Store { s := &Store{def: def, overrides: overrides} for i := range s.shards { s.shards[i].m = make(map[string]*Bucket) } return s } func (s *Store) shardFor(key string) *shard { h := fnv.New32a() h.Write([]byte(key)) return &s.shards[h.Sum32()%shards] } func (s *Store) limitFor(key string) Limit { if l, ok := s.overrides[key]; ok { return l } return s.def } // bucket returns the bucket for key, creating it on first sight. func (s *Store) bucket(key string) *Bucket { sh := s.shardFor(key) sh.mu.Lock() defer sh.mu.Unlock() b, ok := sh.m[key] if !ok { l := s.limitFor(key) b = NewBucket(l.Rate, l.Burst) sh.m[key] = b } return b } // Allow tries to take n tokens from key's bucket. func (s *Store) Allow(key string, n int) Decision { d := s.bucket(key).AllowN(n) if d.Allowed { s.allowed.Add(1) } else { s.denied.Add(1) } return d } // Sweep removes buckets not touched for at least `idle`. Such a bucket has // refilled to full anyway (as long as idle >= burst/rate), so dropping it and // recreating it later is unobservable to clients. Returns how many were removed. func (s *Store) Sweep(idle time.Duration) int { cutoff := time.Now().Add(-idle) removed := 0 for i := range s.shards { sh := &s.shards[i] sh.mu.Lock() for k, b := range sh.m { if last, _ := b.idleSince(); last.Before(cutoff) { delete(sh.m, k) removed++ } } sh.mu.Unlock() } return removed } // Stats is a point-in-time snapshot for observability. type Stats struct { Keys int `json:"keys"` Allowed int64 `json:"allowed_total"` Denied int64 `json:"denied_total"` } // Stats returns counters. Keys is counted shard by shard, not atomically // across shards (fine for metrics; see exercise 09 Snapshot discussion). func (s *Store) Stats() Stats { st := Stats{Allowed: s.allowed.Load(), Denied: s.denied.Load()} for i := range s.shards { sh := &s.shards[i] sh.mu.Lock() st.Keys += len(sh.m) sh.mu.Unlock() } return st }