| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- // Package concurrency is the in-memory per-IP concurrency cap
- // for ingestd (SPEC §22 layer 2). It is shared by:
- //
- // - cmd/ingestd's HTTP server (via the httpserver.ConnState
- // callback that the http handler installs)
- // - cmd/ingestd's WebSocket ingest path (incremented on upgrade,
- // decremented on close)
- //
- // Why in-memory and not Redis: this is the DoS gate. A Redis
- // round-trip on every connection would self-DoS. The cap is
- // approximate (a few extra conns during a multi-replica failover
- // is fine). A janitor goroutine prunes idle entries every minute
- // so the map doesn't grow unbounded.
- package concurrency
- import (
- "sync"
- "sync/atomic"
- "time"
- )
- // PerIP is a per-source-IP concurrency cap. Each unique IP gets
- // an atomic counter; Acquire returns false if the cap is hit.
- // The cap is a hard cap; one process's limit is independent of
- // other ingestd replicas (which is what we want — each replica
- // guards its own TCP listener).
- type PerIP struct {
- cap int64
- mu sync.Mutex
- conns map[string]*atomic.Int64
- idle map[string]time.Time // last released-at
- stop chan struct{}
- }
- // NewPerIP creates a PerIP with the given cap. The cap is the
- // maximum concurrent connections per IP. A non-positive cap
- // disables the gate (Acquire always returns true).
- //
- // The janitor runs every minute and prunes IPs whose counter is
- // zero and whose last-release is older than 5 min. This bounds
- // the map size to "IPs that hit ingestd in the last 5 min",
- // which is small.
- func NewPerIP(cap int) *PerIP {
- p := &PerIP{
- cap: int64(cap),
- conns: make(map[string]*atomic.Int64),
- idle: make(map[string]time.Time),
- stop: make(chan struct{}),
- }
- go p.janitor()
- return p
- }
- // Close stops the janitor. Safe to call multiple times.
- func (p *PerIP) Close() {
- select {
- case <-p.stop:
- default:
- close(p.stop)
- }
- }
- // Acquire tries to increment the counter for ip. Returns true on
- // success, false if the cap is hit. Always increments on success;
- // the caller MUST call Release exactly once per successful
- // Acquire when the connection closes.
- func (p *PerIP) Acquire(ip string) bool {
- if p.cap <= 0 {
- return true // gate disabled
- }
- c := p.counter(ip)
- // Atomic add; if the result exceeds the cap, decrement back
- // and reject. This is the standard compare-and-swap pattern
- // for admission control.
- n := c.Add(1)
- if n > p.cap {
- c.Add(-1)
- return false
- }
- p.touchActive(ip)
- return true
- }
- // Release decrements the counter for ip. Safe to call without a
- // matching Acquire (the counter clamps at 0; we use Add(-1) but
- // the case where the result goes negative is treated as a no-op
- // because the caller is misbehaving — log via Warn, not panic).
- func (p *PerIP) Release(ip string) {
- if p.cap <= 0 {
- return
- }
- p.mu.Lock()
- c, ok := p.conns[ip]
- p.mu.Unlock()
- if !ok {
- return
- }
- n := c.Add(-1)
- if n < 0 {
- c.Add(1) // clamp
- return
- }
- p.touchIdle(ip)
- }
- // InUse returns the current count for ip (0 if not tracked).
- // Useful for tests and the operator's /v1/debug/perip endpoint.
- func (p *PerIP) InUse(ip string) int64 {
- p.mu.Lock()
- c, ok := p.conns[ip]
- p.mu.Unlock()
- if !ok {
- return 0
- }
- v := c.Load()
- if v < 0 {
- return 0
- }
- return v
- }
- func (p *PerIP) counter(ip string) *atomic.Int64 {
- p.mu.Lock()
- defer p.mu.Unlock()
- c, ok := p.conns[ip]
- if !ok {
- c = &atomic.Int64{}
- p.conns[ip] = c
- }
- return c
- }
- func (p *PerIP) touchActive(ip string) {
- p.mu.Lock()
- defer p.mu.Unlock()
- delete(p.idle, ip)
- }
- func (p *PerIP) touchIdle(ip string) {
- p.mu.Lock()
- defer p.mu.Unlock()
- p.idle[ip] = time.Now()
- }
- // janitor prunes idle entries whose counter is 0. The 5-min
- // window is wide enough that a reconnecting source doesn't lose
- // its slot but narrow enough that the map doesn't grow
- // unbounded over weeks of traffic.
- func (p *PerIP) janitor() {
- t := time.NewTicker(time.Minute)
- defer t.Stop()
- for {
- select {
- case <-p.stop:
- return
- case now := <-t.C:
- p.mu.Lock()
- for ip, last := range p.idle {
- if now.Sub(last) > 5*time.Minute {
- c := p.conns[ip]
- if c != nil && c.Load() == 0 {
- delete(p.conns, ip)
- delete(p.idle, ip)
- }
- }
- }
- p.mu.Unlock()
- }
- }
- }
|