// 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() } } }